docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构
AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
|
||||
/// - iOS:<App>/Documents/WebRTCRecordings/
|
||||
class VideoRecorder {
|
||||
MediaRecorder? _recorder;
|
||||
bool _recording = false;
|
||||
String? _currentPath;
|
||||
|
||||
/// 是否正在录制。
|
||||
bool get isRecording => _recording;
|
||||
|
||||
/// 是否为空(从未真正开始)。
|
||||
String? get currentPath => _currentPath;
|
||||
|
||||
/// 开始录制指定媒体流中的视频轨道。
|
||||
///
|
||||
/// 返回 true 表示成功开始;空流 / 无视频轨道会抛异常。
|
||||
Future<bool> start(MediaStream stream) async {
|
||||
if (_recording) return false;
|
||||
final videoTracks = stream.getVideoTracks();
|
||||
if (videoTracks.isEmpty) {
|
||||
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
||||
}
|
||||
final path = await _buildOutputPath();
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建 PeerConnection(recvonly 视频 + 控制用 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user