docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构

AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
2026-08-03 16:12:44 +08:00
parent 1918e5738e
commit d5e66a1777
51 changed files with 4711 additions and 1458 deletions

View File

@@ -0,0 +1,308 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/proto/control_message.pb.dart';
import '../../auth/domain/auth_repository.dart';
import '../domain/signal_message.dart';
import 'signaling_client.dart';
import 'webrtc_controller.dart';
/// 控制端编排器:组合信令客户端与 WebRTC 控制器,
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
class RemoteController {
final String serverUrl;
final String targetDeviceId;
final AuthRepository authRepository;
String? token;
String? authType;
String? authValue;
late final SignalingClient _signaling;
WebRtcController? _webRtc;
Timer? _statsTimer;
/// 本地设备ID由服务端 REGISTER_SUCCESS 下发)。
String? _myDeviceId;
/// 信令状态变化(如"正在连接…"、"已连接…)。
void Function(String status)? onStatusChanged;
/// WebRTC 连接建立(可开始远程控制)。
void Function()? onConnectionEstablished;
/// 连接失败。
void Function(String error)? onConnectionFailed;
/// 连接断开。
void Function()? onDisconnected;
/// ICE 连接断开(用于提示用户并返回连接设置)。
void Function(String message)? onIceDisconnected;
/// 目标被控端不在线(服务器回送 TARGET_OFFLINE用于提示用户并复位 UI
void Function(String message)? onTargetOffline;
/// 被控端拒绝了连接请求(用于提示用户并复位 UI
void Function(String message)? onConnectionRejected;
/// 远端视频渲染器就绪。
void Function(RTCVideoRenderer renderer)? onRemoteStream;
/// 统计信息刷新(每秒一次)。
void Function(String stats)? onStats;
/// 自编码解码纹理已就绪textureId >= 0
void Function(int textureId)? onSelfCodecReady;
/// 自编码解码器已释放(连接断开时)。
void Function()? onSelfCodecLost;
/// 被控端上报当前生效的串流模式。
void Function(int mode)? onStreamModeReport;
/// 被控端/解码器上报分辨率。
void Function(int width, int height)? onResolutionReported;
/// 被控端上报当前采集帧率与支持的帧率档位。
void Function(int width, int height, int fps, List<int> supportedFps)?
onFpsReport;
/// 当前平台不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported;
/// 令牌失效4001用于触发刷新重连。
void Function()? onTokenExpired;
/// 强制下线4003用于跳回登录。
void Function()? onForceLogout;
RemoteController({
required this.serverUrl,
required this.targetDeviceId,
required this.authRepository,
this.token,
this.authType,
this.authValue,
});
/// 发起连接:先确保 accessToken成功后携带 Bearer 建立 WebSocket
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 再建立 WebRTC 并创建 Offer。
Future<void> connect({String? authType, String? authValue}) async {
this.authType = authType;
this.authValue = authValue;
onStatusChanged?.call('状态: 正在连接信令服务器...');
try {
token = await _ensureToken();
} catch (e) {
onStatusChanged?.call('状态: 认证失败 - $e');
onConnectionFailed?.call(e.toString());
return;
}
_signaling = SignalingClient(serverUrl: serverUrl, token: token);
_signaling.onConnected = () {
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
};
_signaling.onMessage = _handleSignalMessage;
_signaling.onDisconnected = () {
onStatusChanged?.call('状态: 已断开连接');
onDisconnected?.call();
};
_signaling.onError = (error) {
onStatusChanged?.call('状态: 连接错误 - $error');
onConnectionFailed?.call(error);
};
_signaling.onTokenExpired = () async {
try {
await authRepository.refresh();
token = authRepository.accessToken;
_reconnect();
} catch (e) {
onStatusChanged?.call('状态: 令牌刷新失败 - $e');
onTokenExpired?.call();
}
};
_signaling.onForceLogout = () {
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
onForceLogout?.call();
};
_signaling.connect();
}
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
Future<String> _ensureToken() async {
final existing = authRepository.accessToken;
if (existing != null) {
try {
await authRepository.verify();
return existing;
} on ApiException catch (e) {
if (e.httpCode != 401) return existing;
}
}
final data = await authRepository.refresh();
return data['accessToken'] as String;
}
void _reconnect() {
_signaling.disconnect();
_signaling.connect();
}
void _initWebRtc() {
final deviceId = _myDeviceId;
if (deviceId == null) {
onStatusChanged?.call('状态: 未获取到本机设备ID连接中止');
return;
}
_webRtc = WebRtcController(
signaling: _signaling,
deviceId: deviceId,
targetDeviceId: targetDeviceId,
authType: authType,
authValue: authValue,
);
_webRtc!.onConnectionEstablished = () {
onConnectionEstablished?.call();
_startStats();
};
_webRtc!.onConnectionFailed = (error) => onConnectionFailed?.call(error);
_webRtc!.onDisconnected = () {
onDisconnected?.call();
};
_webRtc!.onIceDisconnected = (message) => onIceDisconnected?.call(message);
_webRtc!.onRemoteStream = (renderer) => onRemoteStream?.call(renderer);
_webRtc!.onSelfCodecReady = (id) => onSelfCodecReady?.call(id);
_webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call();
_webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode);
_webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h);
_webRtc!.onFpsReport =
(w, h, fps, list) => onFpsReport?.call(w, h, fps, list);
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
onStatusChanged?.call('状态: 已注册 ($deviceId),正在发起连接...');
_webRtc!.initialize().catchError((e) {
onStatusChanged?.call('状态: 连接失败 - $e');
onConnectionFailed?.call(e.toString());
});
}
void _handleSignalMessage(SignalMessage message) {
final type = message.type?.toUpperCase();
if (type == 'REGISTER_SUCCESS') {
// 服务端下发本机 deviceId作为后续 OFFER 的 fromDeviceId。
_myDeviceId = message.fromDeviceId;
_initWebRtc();
// 拉取可连接的被控端绑定列表(仅已绑定设备)。
_loadBindings();
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
_loadTurnCredentials();
return;
}
switch (type) {
case 'ANSWER':
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...');
_webRtc?.handleAnswer(payload['sdp'] as String);
break;
case 'ICE_CANDIDATE':
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
_webRtc?.handleIceCandidate(payload);
break;
case 'TARGET_OFFLINE':
final text = message.payload ?? '目标被控端不在线,请确认设备已开启并连接服务器';
onStatusChanged?.call('状态: $text');
onTargetOffline?.call(text);
break;
case 'CONNECTION_REJECTED':
case 'REQUEST_ERROR':
case 'REQUEST_TIMEOUT':
final text = _parseRejectReason(message.payload);
onStatusChanged?.call('状态: $text');
onConnectionRejected?.call(text);
break;
}
}
/// 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。
String _parseRejectReason(String? payload) {
if (payload == null || payload.isEmpty) return '连接请求失败';
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final r = map['reason'];
if (r is String && r.isNotEmpty) return r;
} catch (_) {}
return payload;
}
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。
Future<void> _loadBindings() async {
try {
final data = await authRepository.bindings();
final list = (data['bindings'] as List?) ?? [];
if (list.isNotEmpty) {
final uids = list.map((e) {
if (e is Map) {
return (e['deviceUid'] ?? e['deviceId'] ?? '').toString();
}
return e.toString();
}).where((s) => s.isNotEmpty).join(', ');
if (uids.isNotEmpty) {
onStatusChanged?.call('已绑定设备: $uids');
}
}
} catch (_) {
// 绑定列表拉取失败不影响主流程。
}
}
/// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。
Future<void> _loadTurnCredentials() async {
final data = await authRepository.turnCredentials();
if (data != null && data['iceServers'] is List) {
final servers = (data['iceServers'] as List)
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
if (servers.isNotEmpty) {
WebRtcController.iceServersOverride = servers;
}
}
}
void _startStats() {
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
final text = await _webRtc?.getStatsText();
if (text != null && text.isNotEmpty) {
onStats?.call(text);
}
});
}
/// 发送控制指令protobuf 二进制)。
void sendControlCommand(ControlMessage command) {
_webRtc?.sendControlCommand(command);
}
/// 请求被控端切换屏幕采集分辨率。
void sendResolutionChange(int width, int height, int fps) {
_webRtc?.sendResolutionChange(width, height, fps);
}
/// 请求被控端切换屏幕串流模式0=WebRTC 全托管 / 1=自编码)。
Future<void> sendStreamMode(int mode) =>
_webRtc?.sendStreamMode(mode) ?? Future.value();
/// 断开连接并释放资源。
Future<void> disconnect() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
_signaling.disconnect();
}
}

View File

@@ -0,0 +1,134 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// 自编码解码器(控制端)。
///
/// 接收来自 `video` DataChannel 的二进制分片(由被控端 [SelfCodecEncoder] 产生),
/// 通过原生 Android `MediaCodec` 硬解码,并渲染到 Flutter 纹理(`Texture`)。
///
/// 为保证与 `WebRTCControlled` 的编码器在二进制协议上完全一致,协议的
/// 分片重组、H.264 参数集解析与硬解、纹理渲染全部在原生侧完成;
/// 本类仅负责与原生侧通信。对应 Android 端 `SelfCodecDecoder`。
///
/// 注意:原生解码器目前仅在 Android 平台实现。其它平台Windows / Linux /
/// Web / iOS调用 [create] 会失败并回调 [onError],调用方应回退到
/// WebRTC 默认媒体流。
class SelfCodecDecoder {
static const MethodChannel _channel =
MethodChannel('com.ttstd.fluttercontroller/self_codec');
/// 串流模式常量(与 ControlMessage.stream_mode 对齐)。
static const int streamModeWebRtc = 0;
static const int streamModeSelfCodec = 1;
int? _textureId;
bool _disposed = false;
bool _handlerSet = false;
/// 解码输出尺寸变化(宽/高,单位像素)。
final void Function(int width, int height)? onSize;
/// 错误回调(如当前平台不支持硬解)。
final void Function(String error)? onError;
SelfCodecDecoder({this.onSize, this.onError}) {
_channel.setMethodCallHandler(_handleNativeCall);
_handlerSet = true;
}
Future<dynamic> _handleNativeCall(MethodCall call) async {
switch (call.method) {
case 'onSize':
final args = call.arguments;
final w = args is Map ? (args['width'] as int? ?? 0) : 0;
final h = args is Map ? (args['height'] as int? ?? 0) : 0;
if (w > 0 && h > 0) onSize?.call(w, h);
break;
case 'onError':
final msg = call.arguments is String ? call.arguments as String : '';
if (msg.isNotEmpty) onError?.call(msg);
break;
}
}
/// 创建解码纹理。成功返回纹理 id>=0失败返回 null。
Future<int?> create() async {
if (_disposed) return null;
try {
final id = await _channel.invokeMethod<int>('create');
if (id == null || id < 0) return null;
_textureId = id;
return _textureId;
} on PlatformException catch (e) {
onError?.call('创建自编码解码纹理失败: ${e.message}');
return null;
} catch (e) {
onError?.call('创建自编码解码纹理失败: $e');
return null;
}
}
/// 已创建的纹理 id未创建或已释放时为 null。
int? get textureId => _textureId;
/// 用于渲染解码画面的纹理控件。
Widget build(BuildContext context) {
final id = _textureId;
if (id == null) return const SizedBox.shrink();
return Texture(textureId: id);
}
/// 喂入来自 video DataChannel 的二进制分片。
Future<void> feed(Uint8List chunk) async {
if (_disposed || _textureId == null) return;
try {
await _channel.invokeMethod<void>('feed', chunk);
} catch (_) {
// 解码未就绪或被释放时忽略。
}
}
/// 启用/停用解码(仅自编码模式为 true
Future<void> setEnabled(bool enabled) async {
if (_disposed || _textureId == null) return;
try {
await _channel.invokeMethod<void>('setEnabled', enabled);
} catch (_) {
// 忽略。
}
}
/// 读取解码耗时统计。返回包含以下键的 Map毫秒失败返回 null
/// - lastMs最近一帧解码耗时
/// - avgMs最近窗口内的平均解码耗时
/// - maxMs峰值解码耗时
/// - count累计已解码帧数。
Future<Map<String, dynamic>?> getStats() async {
if (_disposed || _textureId == null) return null;
try {
final res = await _channel.invokeMethod<dynamic>('getStats');
if (res is Map) return res.cast<String, dynamic>();
} catch (_) {
// 解码未就绪或被释放时忽略。
}
return null;
}
/// 释放原生解码资源。重复调用安全。
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
if (_handlerSet) {
_channel.setMethodCallHandler(null);
_handlerSet = false;
}
try {
await _channel.invokeMethod<void>('dispose');
} catch (_) {
// 忽略。
}
_textureId = null;
}
}

View File

@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../domain/signal_message.dart';
/// 信令客户端,封装 WebSocket 连接与消息收发。
///
/// 鉴权方式:浏览器/移动端原生 WebSocket 均可在握手阶段通过子协议
/// Sec-WebSocket-Protocol传递 Bearer token
/// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.`<token>`'])
/// 服务端从首子协议取出 auth.`<token>` 进行校验。
///
/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
class SignalingClient {
final String serverUrl;
final String? token;
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
/// 收到信令消息回调(已解析为 SignalMessage
void Function(SignalMessage message)? onMessage;
/// 连接成功回调。
void Function()? onConnected;
/// 连接断开回调。
void Function()? onDisconnected;
/// 连接错误回调。
void Function(String error)? onError;
/// 令牌失效(关闭码 4001调用方应刷新令牌后重连。
void Function()? onTokenExpired;
/// 强制下线(关闭码 4003调用方应停止重连并跳登录。
void Function()? onForceLogout;
SignalingClient({required this.serverUrl, this.token});
/// 建立 WebSocket 连接(携带 Bearer 子协议,不再发送 REGISTER
void connect() {
try {
final uri = Uri.parse(serverUrl);
final protocols = ['signal.v1'];
if (token != null && token!.isNotEmpty) {
protocols.add('auth.$token');
}
_channel = WebSocketChannel.connect(uri, protocols: protocols);
_channel!.stream.listen(
_onData,
onDone: _onDone,
onError: (Object e) => onError?.call(e.toString()),
cancelOnError: false,
);
_startHeartbeat();
onConnected?.call();
} catch (e) {
onError?.call(e.toString());
}
}
void _onData(dynamic data) {
if (data is! String) return;
try {
final map = jsonDecode(data) as Map<String, dynamic>;
final message = SignalMessage.fromJson(map);
onMessage?.call(message);
} catch (_) {
// 忽略无法解析的消息。
}
}
void _onDone() {
_stopHeartbeat();
final code = _channel?.closeCode;
if (code == 4001) {
onTokenExpired?.call();
return;
}
if (code == 4003) {
onForceLogout?.call();
return;
}
onDisconnected?.call();
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) {
if (_channel != null) {
try {
_channel!.sink.add(jsonEncode({'type': 'PING'}));
} catch (_) {}
}
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
/// 发送信令消息。
void send(SignalMessage message) => _send(message);
void _send(SignalMessage message) {
_channel?.sink.add(jsonEncode(message.toJson()));
}
/// 关闭连接。
void disconnect() {
_stopHeartbeat();
_channel?.sink.close();
_channel = null;
}
bool get isConnected => _channel != null;
}

View File

@@ -0,0 +1,99 @@
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/&lt;包名&gt;/files/WebRTCRecordings/app 专属外部存储,无需存储权限)
/// - iOS&lt;App&gt;/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();
// ignore: avoid_print
print('[VideoRecorder] 录制开始,保存地址:$path');
_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;
// ignore: avoid_print
print('[VideoRecorder] 录制结束,已保存至:$p');
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

@@ -0,0 +1,859 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../../../app/constants/app_constants.dart';
import '../../../app/constants/ice_servers.dart';
import '../../../core/proto/control_message.pb.dart';
import '../../../core/utils/control_commands.dart';
import '../domain/signal_message.dart';
import 'self_codec_decoder.dart';
import 'signaling_client.dart';
/// WebRTC 全托管模式下,由原生 VideoSink 探针逐帧实测得到的解码耗时统计。
class _DecodeProbeStats {
final double lastMs;
final double avgMs;
final double peakMs;
final int totalFrames;
final int fpsNow;
final int fpsAvg;
const _DecodeProbeStats({
required this.lastMs,
required this.avgMs,
required this.peakMs,
required this.totalFrames,
required this.fpsNow,
required this.fpsAvg,
});
}
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient
///
/// 职责:
/// - 创建 PeerConnectionrecvonly 视频 + 控制用 DataChannel
/// - 创建 Offer 并通过信令发送
/// - 处理 Answer / ICE 候选
/// - 通过 DataChannel 发送控制指令(触摸/滑动/按键)
/// - 采集并解析连接统计信息
///
/// 本类同时兼容 Android 与 iOS基于 flutter_webrtc
class WebRtcController {
final SignalingClient signaling;
final String deviceId;
final String targetDeviceId;
final String? authType;
final String? authValue;
RTCPeerConnection? _pc;
RTCDataChannel? _dataChannel;
RTCDataChannel? _videoDataChannel;
SelfCodecDecoder? _selfCodecDecoder;
int _currentStreamMode = streamModeWebRtc;
bool _creatingDecoder = false;
/// 远端视频渲染器,由调用方持有并显示。
final RTCVideoRenderer renderer = RTCVideoRenderer();
void Function()? onConnectionEstablished;
void Function(String error)? onConnectionFailed;
void Function()? onDisconnected;
/// ICE 连接断开(如网络中断、被控端退出),用于提示用户并返回连接设置。
void Function(String message)? onIceDisconnected;
/// 远端视频流就绪renderer 已绑定视频轨道)。
void Function(RTCVideoRenderer renderer)? onRemoteStream;
/// 自编码解码纹理已就绪textureId >= 0
void Function(int)? onSelfCodecReady;
/// 自编码解码器已释放(连接断开时)。
void Function()? onSelfCodecLost;
/// 被控端上报当前生效的串流模式。
void Function(int mode)? onStreamModeReport;
/// 被控端上报分辨率(自编码解码器输出尺寸变化时也会触发)。
void Function(int width, int height)? onResolutionReported;
/// 被控端上报当前采集帧率与支持的帧率档位(仅 REPORT_RESOLUTION 触发)。
void Function(int width, int height, int fps, List<int> supportedFps)?
onFpsReport;
/// 当前平台Windows / Linux / Web / iOS 等)不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported;
static const String kVideoChannelLabel = 'video_channel';
static const int streamModeWebRtc = 0;
static const int streamModeSelfCodec = 1;
/// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空则用 kIceServers
static List<Map<String, dynamic>>? iceServersOverride;
WebRtcController({
required this.signaling,
required this.deviceId,
required this.targetDeviceId,
this.authType,
this.authValue,
});
/// 初始化渲染器、PeerConnection并创建 Offer。
Future<void> initialize() async {
await renderer.initialize();
final iceServers = iceServersOverride ?? kIceServers;
final configuration = {
'iceServers': iceServers,
'sdpSemantics': 'unified-plan',
'iceCandidatePoolSize': 10,
// 增强复杂网络下的稳定性,参考 Android 端配置。
'continualGatheringPolicy': 'gatherContinually',
'iceTransportsType': 'all',
'tcpCandidatePolicy': 'enabled',
};
final iceUrls = iceServers
.map((e) => '${e['username'] != null ? '(${e['username']})' : ''}${e['urls']}')
.join(', ');
debugPrint('[WebRtcController] signaling server: ${signaling.serverUrl} | '
'deviceId=$deviceId target=$targetDeviceId');
debugPrint('[WebRtcController] ICE servers: $iceUrls');
_pc = await createPeerConnection(configuration);
_pc!.onIceCandidate = _onIceCandidate;
_pc!.onIceConnectionState = _onIceConnectionState;
_pc!.onIceGatheringState = (state) {
debugPrint('[WebRtcController] ICE gathering state: $state');
};
_pc!.onTrack = _onTrack;
_pc!.onDataChannel = _onDataChannel;
// 仅接收远端视频recvonly
await _pc!.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
);
// 创建控制用 DataChannel。
// 使用非可靠、无序模式以降低延迟,解决"不跟手"问题。
final dcInit = RTCDataChannelInit()
..ordered = false
..maxRetransmits = 0;
_dataChannel = await _pc!.createDataChannel(kDataChannelLabel, dcInit);
_setupDataChannel(_dataChannel!);
// 创建自编码视频透传通道(无序、不可靠,类似 RTP最低延迟
// 被控端仅在收到 SET_STREAM_MODE=自编码 后才经此通道推送 H.264 裸流。
final videoInit = RTCDataChannelInit()
..ordered = false
..maxRetransmits = 0;
_videoDataChannel = await _pc!.createDataChannel(
kVideoChannelLabel,
videoInit,
);
_setupVideoDataChannel(_videoDataChannel!);
// 创建并发送 Offer。
final constraints = {
'mandatory': {
'OfferToReceiveVideo': true,
'OfferToReceiveAudio': false,
},
'optional': [],
};
final offer = await _pc!.createOffer(constraints);
// 与 WebRTCControlled 的 optimizeSdp 保持一致Offer 的 m=video 行优先 VP8/VP9
// H264 兜底,确保各控制端(桌面/浏览器/Chromium/Linux都能稳定解码
// 即编码端(被控端)与解码端(控制端)协商出一致的、可解码的视频格式。
final reorderedSdp = _preferVideoCodecs(offer.sdp!);
final offerWithPref = RTCSessionDescription(reorderedSdp, offer.type);
await _pc!.setLocalDescription(offerWithPref);
_sendOffer(reorderedSdp, authType: authType, authValue: authValue);
}
/// 重排 Offer SDP 中 `m=video` 行的视频编解码器顺序VP8/VP9 优先H264 兜底。
///
/// 与 Android 被控端 [WebRtcClient.optimizeSdp] 的编解码优先级保持一致,
/// 保证协商出的编码格式控制端一定可解码(编码/解码一致性)。
String _preferVideoCodecs(String sdp) {
final lines = sdp.split('\r\n');
final vp8 = <String>[];
final vp9 = <String>[];
final h264 = <String>[];
for (final line in lines) {
final t = line.trim();
if (t.startsWith('a=rtpmap:')) {
final payload = t.split(':')[1].split(' ')[0];
if (t.contains('VP8/90000')) {
vp8.add(payload);
} else if (t.contains('VP9/90000')) {
vp9.add(payload);
} else if (t.contains('H264/90000')) {
h264.add(payload);
}
}
}
final ordered = <String>[...vp8, ...vp9, ...h264];
if (ordered.isEmpty) return sdp;
final result = <String>[];
for (final line in lines) {
final t = line.trim();
if (t.startsWith('m=video')) {
final parts = t.split(' ');
if (parts.length > 3) {
final head = '${parts[0]} ${parts[1]} ${parts[2]}';
final rest = parts
.skip(3)
.where((p) => !ordered.contains(p))
.toList();
result.add([head, ...ordered, ...rest].join(' '));
continue;
}
}
result.add(line);
}
return result.join('\r\n');
}
void _onTrack(RTCTrackEvent event) {
if (event.track.kind != 'video') return;
_bindRemoteVideo(event);
}
/// 绑定远端视频轨道到渲染器。
///
/// 与 Web 端 WebRtcController.ontrack 的兜底逻辑保持一致:
/// 某些平台/协商场景下Unified Plan + recvonly`event.streams` 可能为空,
/// 此时必须用 `event.track` 自行构造 MediaStream否则 renderer.srcObject
/// 为空 -> 控制端拿不到画面(黑屏/一直显示"等待画面"),但控制通道不受影响。
Future<void> _bindRemoteVideo(RTCTrackEvent event) async {
MediaStream stream;
if (event.streams.isNotEmpty) {
stream = event.streams[0];
} else {
// event.streams 为空:用 track 自行构造 MediaStream。
stream = await createLocalMediaStream('remoteVideo');
await stream.addTrack(event.track);
}
renderer.srcObject = stream;
onRemoteStream?.call(renderer);
// 仅在 WebRTC 全托管模式 attach 解码耗时探针(自编码模式用自己的解码器统计)。
if (_selfCodecDecoder == null) {
final trackId = event.track.id;
if (trackId != null) _attachWebRtcDecodeProbe(trackId);
}
}
/// 解码耗时探针通道(原生侧给远端视频轨道 attach 一个测量 VideoSink
static const MethodChannel _decodeProbeChannel =
MethodChannel('webrtc_decode_probe');
Future<void> _attachWebRtcDecodeProbe(String trackId) async {
debugPrint('[WebRtcController] Attaching decode probe for track: $trackId');
try {
await _decodeProbeChannel.invokeMethod<void>(
'attachWebRtcDecodeProbe', {'trackId': trackId});
} catch (e) {
debugPrint('[decodeProbe] attach failed: $e');
}
}
Future<void> _detachWebRtcDecodeProbe() async {
try {
await _decodeProbeChannel.invokeMethod<void>('detachWebRtcDecodeProbe');
} catch (e) {
debugPrint('[decodeProbe] detach failed: $e');
}
}
Future<_DecodeProbeStats?> _getWebRtcDecodeStats() async {
try {
final res =
await _decodeProbeChannel.invokeMethod<dynamic>('getWebRtcDecodeStats');
if (res is Map) {
final m = res.cast<String, dynamic>();
return _DecodeProbeStats(
lastMs: (m['lastMs'] as num? ?? 0).toDouble(),
avgMs: (m['avgMs'] as num? ?? 0).toDouble(),
peakMs: (m['peakMs'] as num? ?? 0).toDouble(),
totalFrames: (m['totalFrames'] as num? ?? 0).toInt(),
fpsNow: (m['fpsNow'] as num? ?? 0).toInt(),
fpsAvg: (m['fpsAvg'] as num? ?? 0).toInt(),
);
}
} catch (e) {
debugPrint('[decodeProbe] getStats failed: $e');
}
return null;
}
void _onDataChannel(RTCDataChannel channel) {
// 被控端也可能主动创建 DataChannel按标签统一处理。
if (channel.label == kDataChannelLabel) {
_dataChannel ??= channel;
_setupDataChannel(channel);
} else if (channel.label == kVideoChannelLabel) {
_videoDataChannel ??= channel;
_setupVideoDataChannel(channel);
}
}
void _setupDataChannel(RTCDataChannel channel) {
channel.onMessage = (RTCDataChannelMessage message) {
if (message.isBinary) {
try {
final command = ControlMessage.fromBuffer(message.binary);
_handleControlMessage(command);
} catch (e) {
// ignore: avoid_print
print('DataChannel message decode failed: $e');
}
} else {
// ignore: avoid_print
print('DataChannel text message: ${message.text}');
}
};
channel.onDataChannelState = (RTCDataChannelState state) {
// ignore: avoid_print
print('DataChannel state: $state');
};
}
void _setupVideoDataChannel(RTCDataChannel channel) {
channel.onMessage = (RTCDataChannelMessage message) {
if (message.isBinary) {
_selfCodecDecoder?.feed(message.binary);
}
};
channel.onDataChannelState = (RTCDataChannelState state) {
if (state == RTCDataChannelState.RTCDataChannelOpen) {
_onVideoChannelOpen();
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
_selfCodecDecoder?.setEnabled(false);
}
};
}
void _onVideoChannelOpen() {
_ensureSelfCodecDecoder().then((_) {
_selfCodecDecoder?.setEnabled(_currentStreamMode == streamModeSelfCodec);
});
}
Future<void> _ensureSelfCodecDecoder() async {
if (_selfCodecDecoder != null || _creatingDecoder) return;
_creatingDecoder = true;
try {
final decoder = SelfCodecDecoder(
onSize: (w, h) {
_selfCodecWidth = w;
_selfCodecHeight = h;
onResolutionReported?.call(w, h);
},
onError: (err) => debugPrint('自编码解码器: $err'),
);
final textureId = await decoder.create();
if (textureId != null) {
_selfCodecDecoder = decoder;
onSelfCodecReady?.call(textureId);
} else {
// 当前平台Windows / Linux / Web / iOS 等)无原生硬解能力,回退 WebRTC 媒体流。
onSelfCodecNotSupported?.call();
}
} finally {
_creatingDecoder = false;
}
}
void _handleControlMessage(ControlMessage msg) {
switch (msg.action) {
case Action.REPORT_STREAM_MODE:
_currentStreamMode = msg.streamMode;
if (msg.streamMode == streamModeSelfCodec) {
_ensureSelfCodecDecoder().then((_) {
_selfCodecDecoder?.setEnabled(true);
});
} else {
_selfCodecDecoder?.setEnabled(false);
}
onStreamModeReport?.call(msg.streamMode);
break;
case Action.REPORT_RESOLUTION:
if (msg.width > 0 && msg.height > 0) {
_selfCodecWidth = msg.width;
_selfCodecHeight = msg.height;
}
onResolutionReported?.call(msg.width, msg.height);
// 同步帧率信息(当前帧率 + 被控端按刷新率筛选的帧率档位)
onFpsReport?.call(
msg.width, msg.height, msg.fps, msg.supportedFps.toList());
break;
default:
// ignore: avoid_print
print('DataChannel message: ${msg.action}');
break;
}
}
/// 请求被控端切换屏幕串流模式SET_STREAM_MODE 指令)。
/// [mode] 为 0=WebRTC 全托管 / 1=自编码。
Future<void> sendStreamMode(int mode) async {
_currentStreamMode = mode;
if (mode == streamModeSelfCodec) {
await _ensureSelfCodecDecoder();
await _selfCodecDecoder?.setEnabled(true);
} else {
await _selfCodecDecoder?.setEnabled(false);
}
sendControlCommand(ControlCommands.streamMode(mode));
}
void _onIceCandidate(RTCIceCandidate candidate) {
debugPrint('[WebRtcController] local ICE candidate: ${candidate.candidate}');
final payload = {
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
'candidate': candidate.candidate,
};
final msg = SignalMessage.withPayload(
type: 'ICE_CANDIDATE',
fromDeviceId: deviceId,
toDeviceId: targetDeviceId,
deviceType: 'CONTROLLER',
payload: payload,
);
signaling.send(msg);
}
/// 打印 ICE candidate-pair 统计,用于诊断连接失败原因。
Future<void> _dumpIceStats() async {
try {
final stats = await _pc?.getStats();
final pairs = <String>[];
for (final report in stats ?? []) {
if (report.type == 'candidate-pair') {
final state = report.values['state'] ?? report.values['nominated'];
pairs.add('${report.values['localCandidateId']}->'
'${report.values['remoteCandidateId']} state=$state');
}
}
debugPrint('[WebRtcController] ICE Failed. candidate-pair 数量=${pairs.length}');
for (final p in pairs) {
debugPrint('[WebRtcController] pair: $p');
}
} catch (e) {
debugPrint('[WebRtcController] getStats 失败: $e');
}
}
void _onIceConnectionState(RTCIceConnectionState state) {
// ignore: avoid_print
print('ICE connection state: $state');
if (state == RTCIceConnectionState.RTCIceConnectionStateChecking) {
debugPrint('[WebRtcController] ICE Checking: 正在收集/交换候选,等待连通...');
} else if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
_connectedAt = DateTime.now().millisecondsSinceEpoch;
onConnectionEstablished?.call();
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
onIceDisconnected?.call('ICE 连接已断开');
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
_dumpIceStats();
onConnectionFailed?.call('ICE 连接失败');
onDisconnected?.call();
}
}
void _sendOffer(String sdp, {String? authType, String? authValue}) {
final payload = {'sdp': sdp};
final msg = SignalMessage.withPayload(
type: 'OFFER',
fromDeviceId: deviceId,
toDeviceId: targetDeviceId,
deviceType: 'CONTROLLER',
payload: payload,
authType: authType,
authValue: authValue,
);
signaling.send(msg);
}
/// 处理来自信令服务器的 Answer。
Future<void> handleAnswer(String sdp) async {
final answer = RTCSessionDescription(sdp, 'answer');
await _pc?.setRemoteDescription(answer);
}
/// 处理来自信令服务器的 ICE 候选。
Future<void> handleIceCandidate(Map<String, dynamic> payload) async {
final candidate = RTCIceCandidate(
payload['candidate'] as String,
payload['sdpMid'] as String?,
payload['sdpMLineIndex'] as int?,
);
debugPrint('[WebRtcController] remote ICE candidate: ${candidate.candidate}');
await _pc?.addCandidate(candidate);
}
/// 通过 DataChannel 发送控制指令protobuf 二进制)。
void sendControlCommand(ControlMessage command) {
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
debugPrint('WebRtcController: Sending command -> ${command.action}');
_dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
}
}
/// 请求被控端切换屏幕采集分辨率SET_RESOLUTION 指令)。
void sendResolutionChange(int width, int height, int fps) {
sendControlCommand(ControlCommands.setResolution(width, height, fps));
}
/// 上一次统计采样的字节数与时间戳,用于计算每秒网速。
int _prevBytesReceived = 0;
int _prevBytesSent = 0;
int _prevStatsTimestamp = 0;
/// 上一次采样时的累计解码帧数,用于计算自编码模式下的解码帧率。
int _prevDecodeCount = 0;
/// 上一次采样时的累计解码时间(秒)与解码帧数,用于计算 WebRTC 模式下的解码耗时。
double _prevTotalDecodeTime = 0.0;
int _prevFramesDecoded = 0;
/// WebRTC 全托管模式下,由原生 VideoSink 探针实测的解码耗时统计缓存。
_DecodeProbeStats? _webRtcDecodeProbe;
/// 解码器上报的真实分辨率(自编码模式下 WebRTC 视频统计为空,需用此覆盖)。
int _selfCodecWidth = 0;
int _selfCodecHeight = 0;
/// 连接建立时的时间戳(毫秒),用于计算连接时长。
int? _connectedAt;
/// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats
///
/// 包含:分辨率/帧率/延迟/解码格式、抖动、丢包率、丢帧、
/// 每秒上下行网速、连接类型(P2P/TURN)+候选协议、连接时长、
/// 控制 DataChannel 状态与收发消息数。
Future<String> getStatsText() async {
if (_pc == null) return '';
final reports = await _pc!.getStats();
dynamic width = '-';
dynamic height = '-';
dynamic fps = '-';
dynamic delay = '-';
dynamic codec = '-';
dynamic jitter = '-';
dynamic lossRate = '-';
dynamic framesDropped = '-';
int bytesReceived = 0;
int bytesSent = 0;
String? localCandidateId;
String? remoteCandidateId;
bool hasNominatedPair = false;
// 控制 DataChannel 统计(仅统计与对端的控制通道)。
int dcMessagesSent = 0;
int dcMessagesReceived = 0;
double currentTotalDecodeTime = 0.0;
int currentFramesDecoded = 0;
// 与上次采样的时间差(秒),供循环内估算解码帧率,也供下方计算每秒网速。
final now = DateTime.now().millisecondsSinceEpoch;
final double dtSec =
_prevStatsTimestamp != 0 ? (now - _prevStatsTimestamp) / 1000.0 : 0;
for (final report in reports) {
final values = report.values;
final type = report.type;
if (type == 'inbound-rtp' && values['kind'] == 'video') {
// 关键数据打印,用于排查 totalDecodeTime 是否存在于当前平台的统计中
debugPrint('[WebRtcController] inbound-rtp: totalDecodeTime=${values['totalDecodeTime']}, '
'googDecodeMs=${values['googDecodeMs']}, '
'framesDecoded=${values['framesDecoded']}, '
'fps=${values['framesPerSecond']}');
width = values['frameWidth'] ?? '-';
height = values['frameHeight'] ?? '-';
fps = values['framesPerSecond'] ?? '-';
final j = values['jitter'];
if (j is num) jitter = (j * 1000).toStringAsFixed(0);
final pr = values['packetsReceived'];
final pl = values['packetsLost'];
if (pr is num && pl is num) {
final total = pr + pl;
lossRate = total > 0 ? ((pl / total) * 100).toStringAsFixed(1) : '0.0';
}
final fd = values['framesDropped'];
if (fd is num) framesDropped = fd;
// 核心:解析解码统计(兼容标准字段与旧版 Google 私有字段)
currentTotalDecodeTime = (values['totalDecodeTime'] as num?)?.toDouble() ??
((values['googDecodeMs'] as num?)?.toDouble() ?? 0.0) / 1000.0;
currentFramesDecoded = (values['framesDecoded'] as num?)?.toInt() ??
(values['googFramesDecoded'] as num?)?.toInt() ?? 0;
final codecId = values['codecId'];
if (codecId != null) {
final codecReport = reports.where((r) => r.id == codecId).firstOrNull;
final mime = codecReport?.values['mimeType'];
if (mime is String && mime.startsWith('video/')) {
codec = mime.substring(6);
}
}
} else if (type == 'candidate-pair') {
if (values['nominated'] == true) {
hasNominatedPair = true;
final rtt = values['currentRoundTripTime'];
if (rtt is num) {
delay = (rtt * 1000).toStringAsFixed(0);
}
// candidate-pair 的 bytesReceived/bytesSent 为整条连接(视频+DataChannel+ICE的累计值。
final br = values['bytesReceived'];
final bs = values['bytesSent'];
if (br is num) bytesReceived = br.toInt();
if (bs is num) bytesSent = bs.toInt();
final lc = values['localCandidateId'];
final rc = values['remoteCandidateId'];
if (lc is String) localCandidateId = lc;
if (rc is String) remoteCandidateId = rc;
}
} else if (type == 'data-channel') {
if (values['label'] == kDataChannelLabel) {
final ms = values['messagesSent'];
final mr = values['messagesReceived'];
if (ms is num) dcMessagesSent = ms.toInt();
if (mr is num) dcMessagesReceived = mr.toInt();
}
}
}
// 计算每秒网速(与上次采样差值)。
String downSpeed = '-';
String upSpeed = '-';
if (dtSec > 0) {
final downBps = (bytesReceived - _prevBytesReceived) / dtSec;
final upBps = (bytesSent - _prevBytesSent) / dtSec;
downSpeed = _formatSpeed(downBps);
upSpeed = _formatSpeed(upBps);
}
_prevBytesReceived = bytesReceived;
_prevBytesSent = bytesSent;
_prevStatsTimestamp = now;
// 连接类型P2P/TURN与候选协议udp/tcp
String connType = '-';
String protocol = '-';
if (hasNominatedPair) {
connType =
_resolveConnectionType(reports, localCandidateId, remoteCandidateId);
protocol = _resolveProtocol(reports, localCandidateId, remoteCandidateId);
}
// 连接时长。
String duration = '-';
if (_connectedAt != null) {
final secs = ((now - _connectedAt!) / 1000).floor();
final m = (secs ~/ 60).toString().padLeft(2, '0');
final s = (secs % 60).toString().padLeft(2, '0');
duration = '$m:$s';
}
// 控制 DataChannel 状态。
final dcState = _dataChannel?.state;
final dcStatus = dcState == RTCDataChannelState.RTCDataChannelOpen
? '已连接'
: (dcState == null ? '未连接' : dcState.name);
// —— 分模式统计解码耗时 ——
String? decodeInfo;
// 基础行要展示的解码耗时WebRTC 模式用 inbound-rtp 估算,自编码模式用原生平均。
double nativeAvg = 0;
if (_currentStreamMode == streamModeSelfCodec) {
// 【自编码模式】:视频走 DataChannel 透传裸码流WebRTC 的视频统计为空。
// 使用原生解码器上报的真实分辨率、帧率与耗时。
if (_selfCodecDecoder != null) {
final ds = await _selfCodecDecoder!.getStats();
if (ds != null) {
final last = (ds['lastMs'] as num?)?.toDouble() ?? 0;
final avg = (ds['avgMs'] as num?)?.toDouble() ?? 0;
final peak = (ds['maxMs'] as num?)?.toDouble() ?? 0;
final cnt = (ds['count'] as num?)?.toInt() ?? 0;
nativeAvg = avg;
if (_selfCodecWidth > 0 && _selfCodecHeight > 0) {
width = _selfCodecWidth;
height = _selfCodecHeight;
}
double decodeFps = 0;
if (dtSec > 0 && cnt > 0) {
decodeFps = (cnt - _prevDecodeCount) / dtSec;
}
_prevDecodeCount = cnt;
if (decodeFps > 0) fps = decodeFps.round();
decodeInfo = '解码耗时: ${last.toStringAsFixed(1)} ms 平均: ${avg.toStringAsFixed(1)} ms 峰值: ${peak.toStringAsFixed(1)} ms\n'
'解码帧率: ${decodeFps.toStringAsFixed(0)} fps 已解码: $cnt';
}
}
// 自编码模式下WebRTC 的相关计数建议重置,避免切回时出现跳变。
_prevTotalDecodeTime = currentTotalDecodeTime;
_prevFramesDecoded = currentFramesDecoded;
} else {
// 【WebRTC 模式】:优先使用标准的 getStats() 增量计算耗时。
double avg = 0;
if (currentFramesDecoded > 0) {
if (_prevFramesDecoded > 0 && currentFramesDecoded > _prevFramesDecoded) {
final deltaMs = (currentTotalDecodeTime - _prevTotalDecodeTime) * 1000.0;
final deltaFrames = currentFramesDecoded - _prevFramesDecoded;
if (deltaFrames > 0) avg = deltaMs / deltaFrames;
} else if (currentTotalDecodeTime > 0) {
// 兜底:第一次采样或帧数无增量时,用全量平均作为初始参考
avg = (currentTotalDecodeTime * 1000.0) / currentFramesDecoded;
}
}
if (avg > 0) {
nativeAvg = avg;
decodeInfo = '解码耗时 (stats): ${avg.toStringAsFixed(1)} ms\n'
'解码帧率: $fps fps 已解码: $currentFramesDecoded';
} else {
// 备选方案:如果 getStats 没报数据(部分平台/环境限制),则回退到原生 VideoSink 探针实测模式。
_webRtcDecodeProbe = await _getWebRtcDecodeStats();
final p = _webRtcDecodeProbe;
if (p != null && p.totalFrames > 0) {
nativeAvg = p.avgMs;
decodeInfo =
'解码耗时 (probe): ${p.lastMs.toStringAsFixed(1)} ms 平均: ${p.avgMs.toStringAsFixed(1)} ms 峰值: ${p.peakMs.toStringAsFixed(1)} ms\n'
'解码帧率: ${p.fpsNow} fps (平均 ${p.fpsAvg} fps) 已解码: ${p.totalFrames}';
}
}
// WebRTC 模式下,自编码的计数重置。
_prevDecodeCount = 0;
_prevTotalDecodeTime = currentTotalDecodeTime;
_prevFramesDecoded = currentFramesDecoded;
}
String decodeTimeText = '-';
if (decodeInfo != null && nativeAvg > 0) {
decodeTimeText = '${nativeAvg.toStringAsFixed(1)} ms';
}
final base = '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms 解码耗时: $decodeTimeText\n'
'解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n'
'丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n'
'连接类型: $connType ($protocol) 时长: $duration\n'
'控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived';
return decodeInfo != null ? '$base\n$decodeInfo' : base;
}
/// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
String _resolveConnectionType(
List<StatsReport> reports,
String? localCandidateId,
String? remoteCandidateId,
) {
String? typeOf(String? id) {
if (id == null) return null;
final report = reports.where((r) => r.id == id).firstOrNull;
final t = report?.values['candidateType'];
return t is String ? t : null;
}
final local = typeOf(localCandidateId);
final remote = typeOf(remoteCandidateId);
final isRelay = local == 'relay' || remote == 'relay';
if (isRelay) {
// 进一步用本地候选具体类型细化(优先说明本端是否经 TURN 中继)。
if (local == 'relay') return 'TURN 中继 (本端经服务器)';
return 'TURN 中继';
}
// host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。
return 'P2P 直连';
}
/// 返回选定候选对的传输协议udp/tcp
String _resolveProtocol(
List<StatsReport> reports,
String? localCandidateId,
String? remoteCandidateId,
) {
String protoOf(String? id) {
if (id == null) return '-';
final report = reports.where((r) => r.id == id).firstOrNull;
final p = report?.values['protocol'];
return p is String ? p : '-';
}
final local = protoOf(localCandidateId);
final remote = protoOf(remoteCandidateId);
if (local != '-' && remote != '-' && local != remote) {
return '$local/$remote';
}
return local != '-' ? local : remote;
}
/// 将字节/秒格式化为人类可读字符串B/s、KB/s、MB/s
String _formatSpeed(double bytesPerSecond) {
if (bytesPerSecond >= 1024 * 1024) {
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s';
} else if (bytesPerSecond >= 1024) {
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
}
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
}
/// 释放所有资源。
Future<void> close() async {
try {
await _dataChannel?.close();
} catch (_) {}
try {
await _detachWebRtcDecodeProbe();
} catch (_) {}
try {
await _pc?.close();
} catch (_) {}
try {
await renderer.dispose();
} catch (_) {}
_dataChannel = null;
try {
await _videoDataChannel?.close();
} catch (_) {}
_videoDataChannel = null;
try {
await _selfCodecDecoder?.dispose();
} catch (_) {}
_selfCodecDecoder = null;
_currentStreamMode = streamModeWebRtc;
onSelfCodecLost?.call();
onSelfCodecReady = null;
onStreamModeReport = null;
onResolutionReported = null;
onFpsReport = null;
onSelfCodecNotSupported = null;
_pc = null;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
_prevBytesReceived = 0;
_prevBytesSent = 0;
_prevStatsTimestamp = 0;
_prevDecodeCount = 0;
_prevTotalDecodeTime = 0.0;
_prevFramesDecoded = 0;
_webRtcDecodeProbe = null;
_selfCodecWidth = 0;
_selfCodecHeight = 0;
_connectedAt = null;
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../app/constants/app_constants.dart';
part 'connection_session_state.freezed.dart';
/// 连接控制会话的 UI 状态。
@freezed
class ConnectionSessionState with _$ConnectionSessionState {
const factory ConnectionSessionState({
/// 是否已建立 WebRTC 连接(可开始远程控制)。
@Default(false) bool connected,
/// 是否正在连接信令服务器 / 建立 WebRTC。
@Default(false) bool connecting,
/// 状态提示文本。
@Default('') String status,
/// 连接统计文本(每秒刷新)。
@Default('') String stats,
/// 当前视频宽高比。
@Default(16 / 9) double videoAspect,
/// 当前串流模式0=WebRTC 全托管1=自编码。
@Default(0) int streamMode,
/// 自编码解码纹理 id。
int? selfCodecTextureId,
/// 自编码解码器是否就绪。
@Default(false) bool selfCodecReady,
/// 当前平台是否支持自编码硬解。
@Default(true) bool selfCodecSupported,
/// 是否正在录制远程视频。
@Default(false) bool recording,
/// 录制状态提示。
@Default('') String recordStatus,
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
@Default(false) bool pendingRecordStart,
/// 当前选中的分辨率预设下标。
@Default(0) int selectedResolution,
/// 分辨率预设列表。
@Default(kResolutionOptions) List<Map<String, Object>> resolutionOptions,
/// 帧率档位(收到被控端上报后以上报列表为准)。
@Default(kDefaultFpsOptions) List<int> fpsOptions,
/// 被控端当前采集帧率0 表示尚未收到上报)。
@Default(0) int currentFps,
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
@Default(0) int lastReportedWidth,
/// 被控端最近上报的实际采集尺寸。
@Default(0) int lastReportedHeight,
/// 远端视频渲染器(未连接为 null
RTCVideoRenderer? renderer,
/// 一次性提示消息(消费后由控制器置空)。
String? alert,
}) = _ConnectionSessionState;
}

View File

@@ -0,0 +1,742 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'connection_session_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$ConnectionSessionState {
/// 是否已建立 WebRTC 连接(可开始远程控制)。
bool get connected => throw _privateConstructorUsedError;
/// 是否正在连接信令服务器 / 建立 WebRTC。
bool get connecting => throw _privateConstructorUsedError;
/// 状态提示文本。
String get status => throw _privateConstructorUsedError;
/// 连接统计文本(每秒刷新)。
String get stats => throw _privateConstructorUsedError;
/// 当前视频宽高比。
double get videoAspect => throw _privateConstructorUsedError;
/// 当前串流模式0=WebRTC 全托管1=自编码。
int get streamMode => throw _privateConstructorUsedError;
/// 自编码解码纹理 id。
int? get selfCodecTextureId => throw _privateConstructorUsedError;
/// 自编码解码器是否就绪。
bool get selfCodecReady => throw _privateConstructorUsedError;
/// 当前平台是否支持自编码硬解。
bool get selfCodecSupported => throw _privateConstructorUsedError;
/// 是否正在录制远程视频。
bool get recording => throw _privateConstructorUsedError;
/// 录制状态提示。
String get recordStatus => throw _privateConstructorUsedError;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
bool get pendingRecordStart => throw _privateConstructorUsedError;
/// 当前选中的分辨率预设下标。
int get selectedResolution => throw _privateConstructorUsedError;
/// 分辨率预设列表。
List<Map<String, Object>> get resolutionOptions =>
throw _privateConstructorUsedError;
/// 帧率档位(收到被控端上报后以上报列表为准)。
List<int> get fpsOptions => throw _privateConstructorUsedError;
/// 被控端当前采集帧率0 表示尚未收到上报)。
int get currentFps => throw _privateConstructorUsedError;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
int get lastReportedWidth => throw _privateConstructorUsedError;
/// 被控端最近上报的实际采集尺寸。
int get lastReportedHeight => throw _privateConstructorUsedError;
/// 远端视频渲染器(未连接为 null
RTCVideoRenderer? get renderer => throw _privateConstructorUsedError;
/// 一次性提示消息(消费后由控制器置空)。
String? get alert => throw _privateConstructorUsedError;
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ConnectionSessionStateCopyWith<ConnectionSessionState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ConnectionSessionStateCopyWith<$Res> {
factory $ConnectionSessionStateCopyWith(
ConnectionSessionState value,
$Res Function(ConnectionSessionState) then,
) = _$ConnectionSessionStateCopyWithImpl<$Res, ConnectionSessionState>;
@useResult
$Res call({
bool connected,
bool connecting,
String status,
String stats,
double videoAspect,
int streamMode,
int? selfCodecTextureId,
bool selfCodecReady,
bool selfCodecSupported,
bool recording,
String recordStatus,
bool pendingRecordStart,
int selectedResolution,
List<Map<String, Object>> resolutionOptions,
List<int> fpsOptions,
int currentFps,
int lastReportedWidth,
int lastReportedHeight,
RTCVideoRenderer? renderer,
String? alert,
});
}
/// @nodoc
class _$ConnectionSessionStateCopyWithImpl<
$Res,
$Val extends ConnectionSessionState
>
implements $ConnectionSessionStateCopyWith<$Res> {
_$ConnectionSessionStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connected = null,
Object? connecting = null,
Object? status = null,
Object? stats = null,
Object? videoAspect = null,
Object? streamMode = null,
Object? selfCodecTextureId = freezed,
Object? selfCodecReady = null,
Object? selfCodecSupported = null,
Object? recording = null,
Object? recordStatus = null,
Object? pendingRecordStart = null,
Object? selectedResolution = null,
Object? resolutionOptions = null,
Object? fpsOptions = null,
Object? currentFps = null,
Object? lastReportedWidth = null,
Object? lastReportedHeight = null,
Object? renderer = freezed,
Object? alert = freezed,
}) {
return _then(
_value.copyWith(
connected: null == connected
? _value.connected
: connected // ignore: cast_nullable_to_non_nullable
as bool,
connecting: null == connecting
? _value.connecting
: connecting // ignore: cast_nullable_to_non_nullable
as bool,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as String,
stats: null == stats
? _value.stats
: stats // ignore: cast_nullable_to_non_nullable
as String,
videoAspect: null == videoAspect
? _value.videoAspect
: videoAspect // ignore: cast_nullable_to_non_nullable
as double,
streamMode: null == streamMode
? _value.streamMode
: streamMode // ignore: cast_nullable_to_non_nullable
as int,
selfCodecTextureId: freezed == selfCodecTextureId
? _value.selfCodecTextureId
: selfCodecTextureId // ignore: cast_nullable_to_non_nullable
as int?,
selfCodecReady: null == selfCodecReady
? _value.selfCodecReady
: selfCodecReady // ignore: cast_nullable_to_non_nullable
as bool,
selfCodecSupported: null == selfCodecSupported
? _value.selfCodecSupported
: selfCodecSupported // ignore: cast_nullable_to_non_nullable
as bool,
recording: null == recording
? _value.recording
: recording // ignore: cast_nullable_to_non_nullable
as bool,
recordStatus: null == recordStatus
? _value.recordStatus
: recordStatus // ignore: cast_nullable_to_non_nullable
as String,
pendingRecordStart: null == pendingRecordStart
? _value.pendingRecordStart
: pendingRecordStart // ignore: cast_nullable_to_non_nullable
as bool,
selectedResolution: null == selectedResolution
? _value.selectedResolution
: selectedResolution // ignore: cast_nullable_to_non_nullable
as int,
resolutionOptions: null == resolutionOptions
? _value.resolutionOptions
: resolutionOptions // ignore: cast_nullable_to_non_nullable
as List<Map<String, Object>>,
fpsOptions: null == fpsOptions
? _value.fpsOptions
: fpsOptions // ignore: cast_nullable_to_non_nullable
as List<int>,
currentFps: null == currentFps
? _value.currentFps
: currentFps // ignore: cast_nullable_to_non_nullable
as int,
lastReportedWidth: null == lastReportedWidth
? _value.lastReportedWidth
: lastReportedWidth // ignore: cast_nullable_to_non_nullable
as int,
lastReportedHeight: null == lastReportedHeight
? _value.lastReportedHeight
: lastReportedHeight // ignore: cast_nullable_to_non_nullable
as int,
renderer: freezed == renderer
? _value.renderer
: renderer // ignore: cast_nullable_to_non_nullable
as RTCVideoRenderer?,
alert: freezed == alert
? _value.alert
: alert // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$ConnectionSessionStateImplCopyWith<$Res>
implements $ConnectionSessionStateCopyWith<$Res> {
factory _$$ConnectionSessionStateImplCopyWith(
_$ConnectionSessionStateImpl value,
$Res Function(_$ConnectionSessionStateImpl) then,
) = __$$ConnectionSessionStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
bool connected,
bool connecting,
String status,
String stats,
double videoAspect,
int streamMode,
int? selfCodecTextureId,
bool selfCodecReady,
bool selfCodecSupported,
bool recording,
String recordStatus,
bool pendingRecordStart,
int selectedResolution,
List<Map<String, Object>> resolutionOptions,
List<int> fpsOptions,
int currentFps,
int lastReportedWidth,
int lastReportedHeight,
RTCVideoRenderer? renderer,
String? alert,
});
}
/// @nodoc
class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
extends
_$ConnectionSessionStateCopyWithImpl<$Res, _$ConnectionSessionStateImpl>
implements _$$ConnectionSessionStateImplCopyWith<$Res> {
__$$ConnectionSessionStateImplCopyWithImpl(
_$ConnectionSessionStateImpl _value,
$Res Function(_$ConnectionSessionStateImpl) _then,
) : super(_value, _then);
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connected = null,
Object? connecting = null,
Object? status = null,
Object? stats = null,
Object? videoAspect = null,
Object? streamMode = null,
Object? selfCodecTextureId = freezed,
Object? selfCodecReady = null,
Object? selfCodecSupported = null,
Object? recording = null,
Object? recordStatus = null,
Object? pendingRecordStart = null,
Object? selectedResolution = null,
Object? resolutionOptions = null,
Object? fpsOptions = null,
Object? currentFps = null,
Object? lastReportedWidth = null,
Object? lastReportedHeight = null,
Object? renderer = freezed,
Object? alert = freezed,
}) {
return _then(
_$ConnectionSessionStateImpl(
connected: null == connected
? _value.connected
: connected // ignore: cast_nullable_to_non_nullable
as bool,
connecting: null == connecting
? _value.connecting
: connecting // ignore: cast_nullable_to_non_nullable
as bool,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as String,
stats: null == stats
? _value.stats
: stats // ignore: cast_nullable_to_non_nullable
as String,
videoAspect: null == videoAspect
? _value.videoAspect
: videoAspect // ignore: cast_nullable_to_non_nullable
as double,
streamMode: null == streamMode
? _value.streamMode
: streamMode // ignore: cast_nullable_to_non_nullable
as int,
selfCodecTextureId: freezed == selfCodecTextureId
? _value.selfCodecTextureId
: selfCodecTextureId // ignore: cast_nullable_to_non_nullable
as int?,
selfCodecReady: null == selfCodecReady
? _value.selfCodecReady
: selfCodecReady // ignore: cast_nullable_to_non_nullable
as bool,
selfCodecSupported: null == selfCodecSupported
? _value.selfCodecSupported
: selfCodecSupported // ignore: cast_nullable_to_non_nullable
as bool,
recording: null == recording
? _value.recording
: recording // ignore: cast_nullable_to_non_nullable
as bool,
recordStatus: null == recordStatus
? _value.recordStatus
: recordStatus // ignore: cast_nullable_to_non_nullable
as String,
pendingRecordStart: null == pendingRecordStart
? _value.pendingRecordStart
: pendingRecordStart // ignore: cast_nullable_to_non_nullable
as bool,
selectedResolution: null == selectedResolution
? _value.selectedResolution
: selectedResolution // ignore: cast_nullable_to_non_nullable
as int,
resolutionOptions: null == resolutionOptions
? _value._resolutionOptions
: resolutionOptions // ignore: cast_nullable_to_non_nullable
as List<Map<String, Object>>,
fpsOptions: null == fpsOptions
? _value._fpsOptions
: fpsOptions // ignore: cast_nullable_to_non_nullable
as List<int>,
currentFps: null == currentFps
? _value.currentFps
: currentFps // ignore: cast_nullable_to_non_nullable
as int,
lastReportedWidth: null == lastReportedWidth
? _value.lastReportedWidth
: lastReportedWidth // ignore: cast_nullable_to_non_nullable
as int,
lastReportedHeight: null == lastReportedHeight
? _value.lastReportedHeight
: lastReportedHeight // ignore: cast_nullable_to_non_nullable
as int,
renderer: freezed == renderer
? _value.renderer
: renderer // ignore: cast_nullable_to_non_nullable
as RTCVideoRenderer?,
alert: freezed == alert
? _value.alert
: alert // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
class _$ConnectionSessionStateImpl implements _ConnectionSessionState {
const _$ConnectionSessionStateImpl({
this.connected = false,
this.connecting = false,
this.status = '',
this.stats = '',
this.videoAspect = 16 / 9,
this.streamMode = 0,
this.selfCodecTextureId,
this.selfCodecReady = false,
this.selfCodecSupported = true,
this.recording = false,
this.recordStatus = '',
this.pendingRecordStart = false,
this.selectedResolution = 0,
final List<Map<String, Object>> resolutionOptions = kResolutionOptions,
final List<int> fpsOptions = kDefaultFpsOptions,
this.currentFps = 0,
this.lastReportedWidth = 0,
this.lastReportedHeight = 0,
this.renderer,
this.alert,
}) : _resolutionOptions = resolutionOptions,
_fpsOptions = fpsOptions;
/// 是否已建立 WebRTC 连接(可开始远程控制)。
@override
@JsonKey()
final bool connected;
/// 是否正在连接信令服务器 / 建立 WebRTC。
@override
@JsonKey()
final bool connecting;
/// 状态提示文本。
@override
@JsonKey()
final String status;
/// 连接统计文本(每秒刷新)。
@override
@JsonKey()
final String stats;
/// 当前视频宽高比。
@override
@JsonKey()
final double videoAspect;
/// 当前串流模式0=WebRTC 全托管1=自编码。
@override
@JsonKey()
final int streamMode;
/// 自编码解码纹理 id。
@override
final int? selfCodecTextureId;
/// 自编码解码器是否就绪。
@override
@JsonKey()
final bool selfCodecReady;
/// 当前平台是否支持自编码硬解。
@override
@JsonKey()
final bool selfCodecSupported;
/// 是否正在录制远程视频。
@override
@JsonKey()
final bool recording;
/// 录制状态提示。
@override
@JsonKey()
final String recordStatus;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
@override
@JsonKey()
final bool pendingRecordStart;
/// 当前选中的分辨率预设下标。
@override
@JsonKey()
final int selectedResolution;
/// 分辨率预设列表。
final List<Map<String, Object>> _resolutionOptions;
/// 分辨率预设列表。
@override
@JsonKey()
List<Map<String, Object>> get resolutionOptions {
if (_resolutionOptions is EqualUnmodifiableListView)
return _resolutionOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_resolutionOptions);
}
/// 帧率档位(收到被控端上报后以上报列表为准)。
final List<int> _fpsOptions;
/// 帧率档位(收到被控端上报后以上报列表为准)。
@override
@JsonKey()
List<int> get fpsOptions {
if (_fpsOptions is EqualUnmodifiableListView) return _fpsOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_fpsOptions);
}
/// 被控端当前采集帧率0 表示尚未收到上报)。
@override
@JsonKey()
final int currentFps;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
@override
@JsonKey()
final int lastReportedWidth;
/// 被控端最近上报的实际采集尺寸。
@override
@JsonKey()
final int lastReportedHeight;
/// 远端视频渲染器(未连接为 null
@override
final RTCVideoRenderer? renderer;
/// 一次性提示消息(消费后由控制器置空)。
@override
final String? alert;
@override
String toString() {
return 'ConnectionSessionState(connected: $connected, connecting: $connecting, status: $status, stats: $stats, videoAspect: $videoAspect, streamMode: $streamMode, selfCodecTextureId: $selfCodecTextureId, selfCodecReady: $selfCodecReady, selfCodecSupported: $selfCodecSupported, recording: $recording, recordStatus: $recordStatus, pendingRecordStart: $pendingRecordStart, selectedResolution: $selectedResolution, resolutionOptions: $resolutionOptions, fpsOptions: $fpsOptions, currentFps: $currentFps, lastReportedWidth: $lastReportedWidth, lastReportedHeight: $lastReportedHeight, renderer: $renderer, alert: $alert)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ConnectionSessionStateImpl &&
(identical(other.connected, connected) ||
other.connected == connected) &&
(identical(other.connecting, connecting) ||
other.connecting == connecting) &&
(identical(other.status, status) || other.status == status) &&
(identical(other.stats, stats) || other.stats == stats) &&
(identical(other.videoAspect, videoAspect) ||
other.videoAspect == videoAspect) &&
(identical(other.streamMode, streamMode) ||
other.streamMode == streamMode) &&
(identical(other.selfCodecTextureId, selfCodecTextureId) ||
other.selfCodecTextureId == selfCodecTextureId) &&
(identical(other.selfCodecReady, selfCodecReady) ||
other.selfCodecReady == selfCodecReady) &&
(identical(other.selfCodecSupported, selfCodecSupported) ||
other.selfCodecSupported == selfCodecSupported) &&
(identical(other.recording, recording) ||
other.recording == recording) &&
(identical(other.recordStatus, recordStatus) ||
other.recordStatus == recordStatus) &&
(identical(other.pendingRecordStart, pendingRecordStart) ||
other.pendingRecordStart == pendingRecordStart) &&
(identical(other.selectedResolution, selectedResolution) ||
other.selectedResolution == selectedResolution) &&
const DeepCollectionEquality().equals(
other._resolutionOptions,
_resolutionOptions,
) &&
const DeepCollectionEquality().equals(
other._fpsOptions,
_fpsOptions,
) &&
(identical(other.currentFps, currentFps) ||
other.currentFps == currentFps) &&
(identical(other.lastReportedWidth, lastReportedWidth) ||
other.lastReportedWidth == lastReportedWidth) &&
(identical(other.lastReportedHeight, lastReportedHeight) ||
other.lastReportedHeight == lastReportedHeight) &&
(identical(other.renderer, renderer) ||
other.renderer == renderer) &&
(identical(other.alert, alert) || other.alert == alert));
}
@override
int get hashCode => Object.hashAll([
runtimeType,
connected,
connecting,
status,
stats,
videoAspect,
streamMode,
selfCodecTextureId,
selfCodecReady,
selfCodecSupported,
recording,
recordStatus,
pendingRecordStart,
selectedResolution,
const DeepCollectionEquality().hash(_resolutionOptions),
const DeepCollectionEquality().hash(_fpsOptions),
currentFps,
lastReportedWidth,
lastReportedHeight,
renderer,
alert,
]);
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl>
get copyWith =>
__$$ConnectionSessionStateImplCopyWithImpl<_$ConnectionSessionStateImpl>(
this,
_$identity,
);
}
abstract class _ConnectionSessionState implements ConnectionSessionState {
const factory _ConnectionSessionState({
final bool connected,
final bool connecting,
final String status,
final String stats,
final double videoAspect,
final int streamMode,
final int? selfCodecTextureId,
final bool selfCodecReady,
final bool selfCodecSupported,
final bool recording,
final String recordStatus,
final bool pendingRecordStart,
final int selectedResolution,
final List<Map<String, Object>> resolutionOptions,
final List<int> fpsOptions,
final int currentFps,
final int lastReportedWidth,
final int lastReportedHeight,
final RTCVideoRenderer? renderer,
final String? alert,
}) = _$ConnectionSessionStateImpl;
/// 是否已建立 WebRTC 连接(可开始远程控制)。
@override
bool get connected;
/// 是否正在连接信令服务器 / 建立 WebRTC。
@override
bool get connecting;
/// 状态提示文本。
@override
String get status;
/// 连接统计文本(每秒刷新)。
@override
String get stats;
/// 当前视频宽高比。
@override
double get videoAspect;
/// 当前串流模式0=WebRTC 全托管1=自编码。
@override
int get streamMode;
/// 自编码解码纹理 id。
@override
int? get selfCodecTextureId;
/// 自编码解码器是否就绪。
@override
bool get selfCodecReady;
/// 当前平台是否支持自编码硬解。
@override
bool get selfCodecSupported;
/// 是否正在录制远程视频。
@override
bool get recording;
/// 录制状态提示。
@override
String get recordStatus;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
@override
bool get pendingRecordStart;
/// 当前选中的分辨率预设下标。
@override
int get selectedResolution;
/// 分辨率预设列表。
@override
List<Map<String, Object>> get resolutionOptions;
/// 帧率档位(收到被控端上报后以上报列表为准)。
@override
List<int> get fpsOptions;
/// 被控端当前采集帧率0 表示尚未收到上报)。
@override
int get currentFps;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
@override
int get lastReportedWidth;
/// 被控端最近上报的实际采集尺寸。
@override
int get lastReportedHeight;
/// 远端视频渲染器(未连接为 null
@override
RTCVideoRenderer? get renderer;
/// 一次性提示消息(消费后由控制器置空)。
@override
String? get alert;
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'signal_message.freezed.dart';
part 'signal_message.g.dart';
/// 信令消息模型,对应 Android 端的 SignalMessage。
///
/// 字段含义:
/// - [type] 消息类型REGISTER / OFFER / ANSWER / ICE_CANDIDATE
/// - [fromDeviceId] 发送方设备 ID
/// - [toDeviceId] 接收方设备 ID
/// - [deviceType] 设备类型CONTROLLER / CONTROLLED
/// - [payload] JSON 字符串形式的负载SDP / ICE 候选等)
/// - [authType] 鉴权类型CODE动态验证码/ PASSWORD固定密码
/// - [authValue] 鉴权值:动态验证码或固定密码
@freezed
class SignalMessage with _$SignalMessage {
const SignalMessage._();
const factory SignalMessage({
String? type,
String? fromDeviceId,
String? toDeviceId,
String? deviceType,
String? payload,
String? authType,
String? authValue,
}) = _SignalMessage;
factory SignalMessage.fromJson(Map<String, dynamic> json) =>
_$SignalMessageFromJson(json);
/// 便捷构造方法payload 为任意 Map会自动序列化为 JSON 字符串。
factory SignalMessage.withPayload({
required String type,
required String fromDeviceId,
required String toDeviceId,
required String deviceType,
required Map<String, dynamic> payload,
String? authType,
String? authValue,
}) {
return SignalMessage(
type: type,
fromDeviceId: fromDeviceId,
toDeviceId: toDeviceId,
deviceType: deviceType,
payload: jsonEncode(payload),
authType: authType,
authValue: authValue,
);
}
@override
String toString() => jsonEncode(toJson());
}

View File

@@ -0,0 +1,309 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'signal_message.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
SignalMessage _$SignalMessageFromJson(Map<String, dynamic> json) {
return _SignalMessage.fromJson(json);
}
/// @nodoc
mixin _$SignalMessage {
String? get type => throw _privateConstructorUsedError;
String? get fromDeviceId => throw _privateConstructorUsedError;
String? get toDeviceId => throw _privateConstructorUsedError;
String? get deviceType => throw _privateConstructorUsedError;
String? get payload => throw _privateConstructorUsedError;
String? get authType => throw _privateConstructorUsedError;
String? get authValue => throw _privateConstructorUsedError;
/// Serializes this SignalMessage to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SignalMessage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SignalMessageCopyWith<SignalMessage> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SignalMessageCopyWith<$Res> {
factory $SignalMessageCopyWith(
SignalMessage value,
$Res Function(SignalMessage) then,
) = _$SignalMessageCopyWithImpl<$Res, SignalMessage>;
@useResult
$Res call({
String? type,
String? fromDeviceId,
String? toDeviceId,
String? deviceType,
String? payload,
String? authType,
String? authValue,
});
}
/// @nodoc
class _$SignalMessageCopyWithImpl<$Res, $Val extends SignalMessage>
implements $SignalMessageCopyWith<$Res> {
_$SignalMessageCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SignalMessage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? type = freezed,
Object? fromDeviceId = freezed,
Object? toDeviceId = freezed,
Object? deviceType = freezed,
Object? payload = freezed,
Object? authType = freezed,
Object? authValue = freezed,
}) {
return _then(
_value.copyWith(
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
fromDeviceId: freezed == fromDeviceId
? _value.fromDeviceId
: fromDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
toDeviceId: freezed == toDeviceId
? _value.toDeviceId
: toDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
deviceType: freezed == deviceType
? _value.deviceType
: deviceType // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value.payload
: payload // ignore: cast_nullable_to_non_nullable
as String?,
authType: freezed == authType
? _value.authType
: authType // ignore: cast_nullable_to_non_nullable
as String?,
authValue: freezed == authValue
? _value.authValue
: authValue // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$SignalMessageImplCopyWith<$Res>
implements $SignalMessageCopyWith<$Res> {
factory _$$SignalMessageImplCopyWith(
_$SignalMessageImpl value,
$Res Function(_$SignalMessageImpl) then,
) = __$$SignalMessageImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String? type,
String? fromDeviceId,
String? toDeviceId,
String? deviceType,
String? payload,
String? authType,
String? authValue,
});
}
/// @nodoc
class __$$SignalMessageImplCopyWithImpl<$Res>
extends _$SignalMessageCopyWithImpl<$Res, _$SignalMessageImpl>
implements _$$SignalMessageImplCopyWith<$Res> {
__$$SignalMessageImplCopyWithImpl(
_$SignalMessageImpl _value,
$Res Function(_$SignalMessageImpl) _then,
) : super(_value, _then);
/// Create a copy of SignalMessage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? type = freezed,
Object? fromDeviceId = freezed,
Object? toDeviceId = freezed,
Object? deviceType = freezed,
Object? payload = freezed,
Object? authType = freezed,
Object? authValue = freezed,
}) {
return _then(
_$SignalMessageImpl(
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
fromDeviceId: freezed == fromDeviceId
? _value.fromDeviceId
: fromDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
toDeviceId: freezed == toDeviceId
? _value.toDeviceId
: toDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
deviceType: freezed == deviceType
? _value.deviceType
: deviceType // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value.payload
: payload // ignore: cast_nullable_to_non_nullable
as String?,
authType: freezed == authType
? _value.authType
: authType // ignore: cast_nullable_to_non_nullable
as String?,
authValue: freezed == authValue
? _value.authValue
: authValue // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$SignalMessageImpl extends _SignalMessage {
const _$SignalMessageImpl({
this.type,
this.fromDeviceId,
this.toDeviceId,
this.deviceType,
this.payload,
this.authType,
this.authValue,
}) : super._();
factory _$SignalMessageImpl.fromJson(Map<String, dynamic> json) =>
_$$SignalMessageImplFromJson(json);
@override
final String? type;
@override
final String? fromDeviceId;
@override
final String? toDeviceId;
@override
final String? deviceType;
@override
final String? payload;
@override
final String? authType;
@override
final String? authValue;
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SignalMessageImpl &&
(identical(other.type, type) || other.type == type) &&
(identical(other.fromDeviceId, fromDeviceId) ||
other.fromDeviceId == fromDeviceId) &&
(identical(other.toDeviceId, toDeviceId) ||
other.toDeviceId == toDeviceId) &&
(identical(other.deviceType, deviceType) ||
other.deviceType == deviceType) &&
(identical(other.payload, payload) || other.payload == payload) &&
(identical(other.authType, authType) ||
other.authType == authType) &&
(identical(other.authValue, authValue) ||
other.authValue == authValue));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
type,
fromDeviceId,
toDeviceId,
deviceType,
payload,
authType,
authValue,
);
/// Create a copy of SignalMessage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SignalMessageImplCopyWith<_$SignalMessageImpl> get copyWith =>
__$$SignalMessageImplCopyWithImpl<_$SignalMessageImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SignalMessageImplToJson(this);
}
}
abstract class _SignalMessage extends SignalMessage {
const factory _SignalMessage({
final String? type,
final String? fromDeviceId,
final String? toDeviceId,
final String? deviceType,
final String? payload,
final String? authType,
final String? authValue,
}) = _$SignalMessageImpl;
const _SignalMessage._() : super._();
factory _SignalMessage.fromJson(Map<String, dynamic> json) =
_$SignalMessageImpl.fromJson;
@override
String? get type;
@override
String? get fromDeviceId;
@override
String? get toDeviceId;
@override
String? get deviceType;
@override
String? get payload;
@override
String? get authType;
@override
String? get authValue;
/// Create a copy of SignalMessage
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SignalMessageImplCopyWith<_$SignalMessageImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'signal_message.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SignalMessageImpl _$$SignalMessageImplFromJson(Map<String, dynamic> json) =>
_$SignalMessageImpl(
type: json['type'] as String?,
fromDeviceId: json['fromDeviceId'] as String?,
toDeviceId: json['toDeviceId'] as String?,
deviceType: json['deviceType'] as String?,
payload: json['payload'] as String?,
authType: json['authType'] as String?,
authValue: json['authValue'] as String?,
);
Map<String, dynamic> _$$SignalMessageImplToJson(_$SignalMessageImpl instance) =>
<String, dynamic>{
'type': instance.type,
'fromDeviceId': instance.fromDeviceId,
'toDeviceId': instance.toDeviceId,
'deviceType': instance.deviceType,
'payload': instance.payload,
'authType': instance.authType,
'authValue': instance.authValue,
};

View File

@@ -0,0 +1,277 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/proto/control_message.pb.dart';
import '../../auth/data/auth_providers.dart';
import '../data/remote_controller.dart';
import '../data/self_codec_decoder.dart';
import '../data/video_recorder.dart';
import '../domain/connection_session_state.dart';
part 'connection_controller.g.dart';
/// 连接控制会话控制器:编排信令 + WebRTC管理控制端全部 UI 状态。
@Riverpod(keepAlive: true)
class ConnectionController extends _$ConnectionController {
/// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。
static const MethodChannel _windowChannel = MethodChannel('app/window');
RemoteController? _remote;
RTCVideoRenderer? _renderer;
final VideoRecorder _videoRecorder = VideoRecorder();
/// 记录上一次已应用的视频宽高比,避免重复调整窗口。
double _lastResizedAspect = 0;
@override
ConnectionSessionState build() {
ref.onDispose(() {
_videoRecorder.dispose();
_remote?.disconnect();
});
return const ConnectionSessionState(status: '状态: 已停止');
}
void _setStatus(String status) => state = state.copyWith(status: status);
void _alert(String message) => state = state.copyWith(alert: message);
void _clearAlert() => state = state.copyWith(alert: null);
/// 发起连接:先确保已登录,携带 Bearer 建立信令,成功后建立 WebRTC。
Future<void> connect({
required String serverUrl,
required String targetDeviceId,
required String authType,
required String authValue,
}) async {
if (serverUrl.isEmpty || targetDeviceId.isEmpty) {
_alert('请填写服务器地址和目标设备ID');
return;
}
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...');
_remote = RemoteController(
serverUrl: serverUrl,
targetDeviceId: targetDeviceId,
authRepository: authRepository,
);
_wireRemoteCallbacks();
await _remote!.connect(authType: authType, authValue: authValue);
}
void _wireRemoteCallbacks() {
final remote = _remote!;
remote.onStatusChanged = _setStatus;
remote.onConnectionEstablished = () {
state = state.copyWith(
connected: true,
connecting: false,
status: '状态: 已连接 - 远程控制中',
);
};
remote.onConnectionFailed = (error) {
state = state.copyWith(connecting: false);
_alert('连接失败:$error');
};
remote.onDisconnected = () {
state = state.copyWith(connected: false, connecting: false);
_setStatus('状态: 远端已断开');
};
remote.onIceDisconnected = (message) {
_alert(message);
disconnect();
};
remote.onTargetOffline = (message) {
_alert(message);
state = state.copyWith(connected: false, connecting: false);
};
remote.onConnectionRejected = (message) {
_alert(message);
state = state.copyWith(connected: false, connecting: false);
};
remote.onRemoteStream = (renderer) {
_renderer = renderer;
state = state.copyWith(renderer: renderer);
renderer.addListener(_onRendererUpdate);
if (state.pendingRecordStart) {
state = state.copyWith(pendingRecordStart: false);
_startRecording();
}
};
remote.onStats = (stats) => state = state.copyWith(stats: stats);
remote.onSelfCodecReady = (textureId) {
state = state.copyWith(
selfCodecTextureId: textureId,
selfCodecReady: true,
);
};
remote.onSelfCodecLost = () {
state = state.copyWith(selfCodecReady: false, selfCodecTextureId: null);
};
remote.onStreamModeReport = (mode) {
state = state.copyWith(streamMode: mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) {
state = state.copyWith(pendingRecordStart: false);
_startRecording();
}
};
remote.onResolutionReported = (w, h) {
if (w > 0 && h > 0) {
final aspect = w / h;
state = state.copyWith(videoAspect: aspect);
_resizeWindowToAspect(aspect);
}
};
remote.onFpsReport = (w, h, fps, supportedFps) {
state = state.copyWith(
lastReportedWidth: w > 0 ? w : state.lastReportedWidth,
lastReportedHeight: h > 0 ? h : state.lastReportedHeight,
currentFps: fps > 0 ? fps : state.currentFps,
fpsOptions: supportedFps.isNotEmpty ? supportedFps : state.fpsOptions,
);
};
remote.onSelfCodecNotSupported = () {
state = state.copyWith(selfCodecSupported: false);
_alert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
};
remote.onTokenExpired = () {
_alert('登录已失效,请重新登录后再连接。');
_resetLogin();
};
remote.onForceLogout = () {
_alert('账号已在其他位置登录,已强制下线。');
_resetLogin();
};
}
Future<void> _resetLogin() async {
await ref.read(authRepositoryProvider).clear();
await disconnect();
state = state.copyWith(connected: false, connecting: false);
}
void _onRendererUpdate() {
final w = _renderer?.value.width ?? 0;
final h = _renderer?.value.height ?? 0;
if (w > 0 && h > 0) {
final aspect = w / h;
state = state.copyWith(videoAspect: aspect);
_resizeWindowToAspect(aspect);
}
}
/// Windows 端:保持窗口宽度不变,按视频宽高比调整窗口高度。
void _resizeWindowToAspect(double aspect) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.windows) return;
if (aspect <= 0) return;
if ((aspect - _lastResizedAspect).abs() < 0.001) return;
_lastResizedAspect = aspect;
_windowChannel.invokeMethod('resizeToVideoAspect', aspect);
}
/// 切换分辨率:写入状态并下发指令。
void selectResolution(int index) {
state = state.copyWith(selectedResolution: index);
final o = state.resolutionOptions[index];
_remote?.sendResolutionChange(
o['width'] as int,
o['height'] as int,
o['fps'] as int,
);
}
/// 切换帧率:仅切帧率,分辨率保持不变。
void selectFps(int fps) {
if (fps <= 0 || fps == state.currentFps) return;
_remote?.sendResolutionChange(
state.lastReportedWidth,
state.lastReportedHeight,
fps,
);
}
/// 切换串流模式WebRTC 全托管 <-> 自编码。
void toggleStreamMode() {
if (!state.selfCodecSupported) {
_alert('当前平台不支持自编码硬解。');
return;
}
final next = state.streamMode == SelfCodecDecoder.streamModeSelfCodec
? SelfCodecDecoder.streamModeWebRtc
: SelfCodecDecoder.streamModeSelfCodec;
state = state.copyWith(streamMode: next);
_remote?.sendStreamMode(next);
}
/// 切换远程视频录制:开始 / 停止。
Future<void> toggleRecord() async {
if (state.recording) {
await _stopRecording();
return;
}
final renderer = _renderer;
if (renderer == null) {
_alert('尚未连接或没有视频画面,无法录制');
return;
}
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec) {
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
state = state.copyWith(
pendingRecordStart: true,
recordStatus: '正在切回标准模式以开始录制...',
);
_remote?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
return;
}
await _startRecording();
}
Future<void> _startRecording() async {
if (state.recording) return;
final stream = _renderer?.srcObject;
if (stream == null) {
_alert('尚未接收到视频画面,无法录制');
return;
}
try {
final ok = await _videoRecorder.start(stream);
if (ok) {
state = state.copyWith(recording: true, recordStatus: '录制中...');
}
} catch (e) {
state = state.copyWith(recordStatus: '');
_alert('开始录制失败:$e');
}
}
Future<void> _stopRecording() async {
final path = await _videoRecorder.stop();
state = state.copyWith(
recording: false,
recordStatus: path != null ? '已保存:$path' : '录制已停止',
);
}
/// 发送控制指令protobuf 二进制)。
void sendControlCommand(ControlMessage command) {
_remote?.sendControlCommand(command);
}
/// 断开连接并复位全部 UI 状态。
Future<void> disconnect() async {
await _videoRecorder.dispose();
await _remote?.disconnect();
_remote = null;
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
state = const ConnectionSessionState(status: '状态: 已停止');
}
/// 消费一次性提示消息UI 展示后调用)。
void consumeAlert() => _clearAlert();
}

View File

@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connection_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$connectionControllerHash() =>
r'bea162831e84bb80f7921d7c774d76e07969b023';
/// 连接控制会话控制器:编排信令 + WebRTC管理控制端全部 UI 状态。
///
/// Copied from [ConnectionController].
@ProviderFor(ConnectionController)
final connectionControllerProvider =
NotifierProvider<ConnectionController, ConnectionSessionState>.internal(
ConnectionController.new,
name: r'connectionControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$connectionControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ConnectionController = Notifier<ConnectionSessionState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,369 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../core/utils/control_commands.dart';
import '../../../../core/widgets/remote_touch_view.dart';
import '../../data/self_codec_decoder.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
/// 控制面板页:显示远端视频、触摸控制、顶部菜单栏与状态浮层。
class ControlPage extends ConsumerStatefulWidget {
const ControlPage({super.key});
@override
ConsumerState<ControlPage> createState() => _ControlPageState();
}
class _ControlPageState extends ConsumerState<ControlPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final connection = ref.watch(connectionControllerProvider);
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if (next.alert != null) {
final message = next.alert!;
// 消费一次性提示消息
Future.microtask(() {
ref.read(connectionControllerProvider.notifier).consumeAlert();
});
_showAlert(message);
}
// 断开连接后返回设置页
if ((prev?.connected ?? false) && !next.connected) {
context.go('/');
}
},
);
return CupertinoPageScaffold(
navigationBar: null,
child: SafeArea(
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
// 以便右下角的断开按钮贴近屏幕边缘。
top: true,
bottom: false,
left: false,
right: false,
child: Stack(
children: [
Container(color: CupertinoColors.black),
_buildVideoLayer(connection),
_buildStatusOverlay(connection),
Positioned(
top: 8,
right: 8,
child: _buildTopMenuBar(connection, l10n),
),
],
),
),
);
}
Widget _buildVideoLayer(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
final touchLayer = RemoteTouchView(
// 禁用离散的 TOUCH/SWIPE/LONG_PRESS 指令,改用实时的 onMotionEvent 以解决重复操作问题。
// 原始的动作流已包含完整的触摸过程,被控端系统会自动识别单击、滑动和长按。
onTouch: (x, y) {},
onSwipe: (x1, y1, x2, y2, d) {},
onLongPress: (x, y) {},
onKey: (k, a) => controller.sendControlCommand(ControlCommands.key(k, a)),
onMotionEvent: (a, x, y) =>
controller.sendControlCommand(ControlCommands.motionEvent(a, x, y)),
);
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec &&
state.selfCodecReady &&
state.selfCodecTextureId != null) {
return Center(
child: AspectRatio(
aspectRatio: state.videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
Texture(textureId: state.selfCodecTextureId!),
touchLayer,
],
),
),
);
}
if (state.renderer != null) {
return Center(
child: AspectRatio(
aspectRatio: state.videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
RTCVideoView(
state.renderer!,
objectFit:
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
touchLayer,
],
),
),
);
}
return const SizedBox.shrink();
}
Widget _buildStatusOverlay(ConnectionSessionState state) {
return Positioned(
top: 0,
left: 0,
child: Container(
color: CupertinoColors.black.withValues(alpha: 0.54),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.status, style: const TextStyle(color: CupertinoColors.white)),
Text(
state.stats,
style: const TextStyle(color: CupertinoColors.white, fontSize: 12),
),
if (state.recordStatus.isNotEmpty)
Text(
state.recordStatus,
style: const TextStyle(
color: CupertinoColors.systemOrange,
fontSize: 12,
),
),
],
),
),
);
}
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
Widget _buildTopMenuBar(ConnectionSessionState state, AppLocalizations l10n) {
final controller = ref.read(connectionControllerProvider.notifier);
final label = state.resolutionOptions[state.selectedResolution]['label']
as String;
return Container(
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(22),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 分辨率菜单按钮:显示当前分辨率标签
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () => _showResolutionMenu(state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.slider_horizontal_3,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 帧率菜单按钮:显示被控端当前采集帧率
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () => _showFpsMenu(state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.speedometer,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
state.currentFps > 0 ? '${state.currentFps}fps' : '帧率',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: state.selfCodecSupported ? controller.toggleStreamMode : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.selfCodec,
style: TextStyle(
color: state.selfCodecSupported
? CupertinoColors.white
: CupertinoColors.white.withValues(alpha: 0.4),
fontSize: 14,
),
),
const SizedBox(width: 6),
CupertinoSwitch(
value: state.streamMode ==
SelfCodecDecoder.streamModeSelfCodec,
onChanged: state.selfCodecSupported
? (_) => controller.toggleStreamMode()
: null,
activeTrackColor: CupertinoColors.activeBlue,
),
],
),
),
// 远程视频录制开关
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: controller.toggleRecord,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
state.recording
? CupertinoIcons.stop_circle
: CupertinoIcons.video_camera,
color: state.recording
? CupertinoColors.destructiveRed
: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
state.recording ? l10n.stop : l10n.record,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 分隔线
Container(
width: 1,
height: 22,
color: CupertinoColors.white.withValues(alpha: 0.3),
),
// 断开连接按钮
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () async {
await ref.read(connectionControllerProvider.notifier).disconnect();
if (mounted) context.go('/');
},
child: const Icon(
CupertinoIcons.xmark_circle_fill,
color: CupertinoColors.destructiveRed,
size: 24,
),
),
],
),
);
}
/// 弹出分辨率选择菜单iOS 风格 ActionSheet
void _showResolutionMenu(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换分辨率'),
actions: [
for (int i = 0; i < state.resolutionOptions.length; i++)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectResolution(i);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == state.selectedResolution) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text(state.resolutionOptions[i]['label'] as String),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 弹出帧率选择菜单iOS 风格 ActionSheet仅切帧率分辨率保持不变。
void _showFpsMenu(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in state.fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectFps(fps);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == state.currentFps) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text('${fps}fps'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
void _showAlert(String message) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../app/constants/app_constants.dart';
import '../../../auth/presentation/auth_controller.dart';
import '../../../auth/presentation/widgets/login_dialog.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
import '../widgets/auth_dialog.dart';
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
class SetupPage extends ConsumerStatefulWidget {
const SetupPage({super.key});
@override
ConsumerState<SetupPage> createState() => _SetupPageState();
}
class _SetupPageState extends ConsumerState<SetupPage> {
final _serverUrlController = TextEditingController(
text: kDefaultSignalServer,
);
final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
@override
void dispose() {
_serverUrlController.dispose();
_deviceIdController.dispose();
_targetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState = ref.watch(authControllerProvider).valueOrNull;
final connection = ref.watch(connectionControllerProvider);
// 连接建立后跳转控制页。
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if ((prev?.connected ?? false) == false && next.connected) {
context.go('/control');
}
},
);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.appTitle),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.appTitle,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _serverUrlController,
placeholder: l10n.serverUrlPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _deviceIdController,
placeholder: l10n.deviceIdPlaceholder,
enabled: false,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: l10n.targetDeviceIdPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 24),
Text(
connection.status,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: (authState?.loggedIn ?? false)
? () async {
await ref
.read(authControllerProvider.notifier)
.logout();
}
: () async {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (_) => const LoginDialog(),
);
if (ok != true && mounted) {
ref.read(connectionControllerProvider.notifier)
.consumeAlert();
}
},
child: Text(
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: connection.connecting ? null : _onConnectPressed,
child: Text(
connection.connecting ? l10n.connecting : l10n.connectDevice,
),
),
),
],
),
),
),
);
}
Future<void> _onConnectPressed() async {
final l10n = AppLocalizations.of(context);
final serverUrl = _serverUrlController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || target.isEmpty) {
_showAlert(l10n.serverAndTargetRequired);
return;
}
// 连接前确保已登录Bearer token
final authState = ref.read(authControllerProvider).valueOrNull;
final loggedIn = authState?.loggedIn ?? false;
if (!loggedIn) {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (_) => const LoginDialog(),
);
if (ok != true || !mounted) return;
}
final selection = await showAuthDialog(context);
if (selection == null || !mounted) return;
await ref.read(connectionControllerProvider.notifier).connect(
serverUrl: serverUrl,
targetDeviceId: target,
authType: selection.type,
authValue: selection.value,
);
}
void _showAlert(String message) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/cupertino.dart';
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
/// 鉴权方式枚举。
enum AuthMode { none, code, password }
/// 连接鉴权对话框:选择免密 / 动态验证码 / 固定密码。
///
/// 返回 [AuthSelection];取消返回 null。
Future<AuthSelection?> showAuthDialog(BuildContext context) {
return showCupertinoDialog<AuthSelection>(
context: context,
builder: (ctx) => const AuthDialog(),
);
}
class AuthDialog extends StatefulWidget {
const AuthDialog({super.key});
@override
State<AuthDialog> createState() => _AuthDialogState();
}
class _AuthDialogState extends State<AuthDialog> {
AuthMode _selected = AuthMode.none;
final _valueController = TextEditingController();
@override
void dispose() {
_valueController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Widget buildOption(
AuthMode mode,
String title,
String desc,
) {
final selected = _selected == mode;
return GestureDetector(
onTap: () => setState(() => _selected = mode),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
border: Border.all(
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey4,
),
borderRadius: BorderRadius.circular(10),
color: selected
? CupertinoColors.activeBlue.withValues(alpha: 0.06)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
desc,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
selected
? CupertinoIcons.check_mark_circled_solid
: CupertinoIcons.circle,
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey,
),
],
),
),
);
}
return CupertinoAlertDialog(
title: Text(l10n.authTitle),
content: Column(
children: [
const SizedBox(height: 12),
buildOption(
AuthMode.none,
l10n.authNone,
l10n.authNoneDesc,
),
buildOption(
AuthMode.code,
l10n.authCode,
l10n.authCodeDesc,
),
buildOption(
AuthMode.password,
l10n.authPassword,
l10n.authPasswordDesc,
),
const SizedBox(height: 4),
CupertinoTextField(
controller: _valueController,
enabled: _selected != AuthMode.none,
placeholder: switch (_selected) {
AuthMode.none => l10n.authNonePlaceholder,
AuthMode.password => l10n.authPasswordPlaceholder,
AuthMode.code => l10n.authCodePlaceholder,
},
obscureText: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(context).pop(),
),
CupertinoDialogAction(
child: Text(l10n.connect),
onPressed: () {
final val = _valueController.text.trim();
if (_selected != AuthMode.none && val.isEmpty) {
_showAlert(context, l10n.authValueRequired);
return;
}
Navigator.of(context).pop(
AuthSelection(
mode: _selected,
value: _selected == AuthMode.none ? '' : val,
),
);
},
),
],
);
}
static void _showAlert(BuildContext context, String message) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}
/// 鉴权选择结果。
class AuthSelection {
final AuthMode mode;
final String value;
const AuthSelection({required this.mode, required this.value});
String get type => switch (mode) {
AuthMode.none => 'NONE',
AuthMode.code => 'CODE',
AuthMode.password => 'PASSWORD',
};
}