feat(remote): 修复键盘输入映射并重构连接流程
- Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题 - Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发 - 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换 - AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法 - DioAuthRepository实现401自动刷新token重试机制 - ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段
This commit is contained in:
@@ -117,7 +117,12 @@ public class SystemInputUtils implements InputExecutor {
|
||||
}
|
||||
|
||||
private void injectKeyEvent(long downTime, long eventTime, int action, int keyCode) {
|
||||
// 第 5 个参数 metaState=0;最后一个参数 flags=0。
|
||||
// 关键:必须显式设置事件来源为键盘(SOURCE_KEYBOARD),否则通过 InputManager
|
||||
// 注入的按键事件会被系统/输入法(IME)丢弃,导致焦点在 EditText 等输入框时
|
||||
// 收不到字符输入。
|
||||
KeyEvent event = new KeyEvent(downTime, eventTime, action, keyCode, 0);
|
||||
event.setSource(InputDevice.SOURCE_KEYBOARD);
|
||||
try {
|
||||
mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
|
||||
|
||||
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
|
||||
@@ -8,6 +10,63 @@ import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
|
||||
class ControlCommands {
|
||||
ControlCommands._();
|
||||
|
||||
/// 将 Flutter 的 [LogicalKeyboardKey] 转换为 Android 标准 [KeyEvent.keyCode]。
|
||||
///
|
||||
/// 说明:Flutter 的 [LogicalKeyboardKey.keyId] 是 Unicode 抽象值(如字母 'w'
|
||||
/// 的 keyId 为 0x77=119),而 Android 端注入按键时使用的是 [KeyEvent.keyCode]
|
||||
/// (如 KEYCODE_W=50)。两者语义不同,若直接把 Flutter 的 keyId 当作 Android
|
||||
/// keyCode 发给被控端,'w' 会被误识别为 KEYCODE_F8(119) 等,导致触发系统组合键
|
||||
/// /功能键(屏幕乱跳、输入框无字符输入)。因此这里统一转换成 Android keyCode。
|
||||
static int toAndroidKeyCode(LogicalKeyboardKey key) {
|
||||
// 字母 a-z / A-Z:Android KEYCODE_A=29 起
|
||||
const a = 0x61; // 'a'
|
||||
const z = 0x7a; // 'z'
|
||||
const au = 0x41; // 'A'
|
||||
const zu = 0x5a; // 'Z'
|
||||
final id = key.keyId;
|
||||
if (id >= a && id <= z) return id - a + 29;
|
||||
if (id >= au && id <= zu) return id - au + 29;
|
||||
// 数字 0-9:Android KEYCODE_0=7 起
|
||||
if (id >= 0x30 && id <= 0x39) return id - 0x30 + 7;
|
||||
|
||||
// 其余按键用 LogicalKeyboardKey 常量精确匹配,避免魔法数字。
|
||||
// 注意:LogicalKeyboardKey 重写了 ==/hashCode,不能作为 const map 的 key,
|
||||
// 因此使用普通(非 const)Map 字面量。
|
||||
const home = LogicalKeyboardKey.home;
|
||||
const end = LogicalKeyboardKey.end;
|
||||
final map = <LogicalKeyboardKey, int>{
|
||||
LogicalKeyboardKey.space: 62,
|
||||
LogicalKeyboardKey.enter: 66,
|
||||
LogicalKeyboardKey.numpadEnter: 66,
|
||||
LogicalKeyboardKey.backspace: 67,
|
||||
LogicalKeyboardKey.tab: 61,
|
||||
LogicalKeyboardKey.escape: 111,
|
||||
LogicalKeyboardKey.delete: 112,
|
||||
home: 122,
|
||||
end: 123,
|
||||
LogicalKeyboardKey.pageUp: 92,
|
||||
LogicalKeyboardKey.pageDown: 93,
|
||||
LogicalKeyboardKey.arrowLeft: 21,
|
||||
LogicalKeyboardKey.arrowUp: 19,
|
||||
LogicalKeyboardKey.arrowRight: 22,
|
||||
LogicalKeyboardKey.arrowDown: 20,
|
||||
// 功能键 F1-F12:Android 131..142
|
||||
LogicalKeyboardKey.f1: 131,
|
||||
LogicalKeyboardKey.f2: 132,
|
||||
LogicalKeyboardKey.f3: 133,
|
||||
LogicalKeyboardKey.f4: 134,
|
||||
LogicalKeyboardKey.f5: 135,
|
||||
LogicalKeyboardKey.f6: 136,
|
||||
LogicalKeyboardKey.f7: 137,
|
||||
LogicalKeyboardKey.f8: 138,
|
||||
LogicalKeyboardKey.f9: 139,
|
||||
LogicalKeyboardKey.f10: 140,
|
||||
LogicalKeyboardKey.f11: 141,
|
||||
LogicalKeyboardKey.f12: 142,
|
||||
};
|
||||
return map[key] ?? 0;
|
||||
}
|
||||
|
||||
/// 单击指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
|
||||
static ControlMessage touch(double x, double y) => ControlMessage(
|
||||
action: Action.TOUCH,
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:webrtc_controller_flutter/core/utils/control_commands.dart';
|
||||
|
||||
/// 远端触摸控制层(对应 Android 端 RemoteTouchView)。
|
||||
///
|
||||
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
|
||||
@@ -43,6 +45,10 @@ class RemoteTouchView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
/// 用于捕获物理键盘事件的焦点节点。
|
||||
/// 设置 [FocusNode.skipTraversal] 避免桌面端获得焦点时绘制一圈绿色高亮边框。
|
||||
final FocusNode _focusNode = FocusNode(skipTraversal: true);
|
||||
|
||||
Offset? _startPosition;
|
||||
Offset? _currentPosition;
|
||||
DateTime? _startTime;
|
||||
@@ -77,19 +83,41 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelLongPress();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 关闭框架的焦点高亮描边:本控件需要 autofocus 捕获物理键盘,但框架在
|
||||
// 检测到键盘交互(传统高亮模式)后会给焦点节点绘制一圈绿色高亮边框。
|
||||
// 将高亮策略设为 alwaysTouch 后边框不再绘制,键盘事件捕获不受影响。
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
focusColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
),
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
// 必须将 Flutter 的逻辑键转换为 Android keyCode 再下发,否则会被控端
|
||||
// 误识别为功能键/组合键(屏幕乱跳、输入框无法输入文字)。
|
||||
final int androidKeyCode = ControlCommands.toAndroidKeyCode(event.logicalKey);
|
||||
if (androidKeyCode == 0) return KeyEventResult.ignored;
|
||||
if (event is KeyDownEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 0);
|
||||
widget.onKey(androidKeyCode, 0);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event is KeyUpEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 1);
|
||||
widget.onKey(androidKeyCode, 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
@@ -177,6 +205,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../app/constants/app_constants.dart';
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
import '../../connection/domain/binding_device.dart';
|
||||
|
||||
/// 基于 Dio 的 [AuthRepository] 实现。
|
||||
class DioAuthRepository implements AuthRepository {
|
||||
@@ -101,13 +102,41 @@ class DioAuthRepository implements AuthRepository {
|
||||
Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||||
Future<List<BindingDevice>> getBindings() async {
|
||||
final data = await _get('/api/client/bindings');
|
||||
final list = (data['bindings'] as List?) ?? [];
|
||||
return [
|
||||
for (final e in list)
|
||||
if (e is Map)
|
||||
BindingDevice.fromJson(e.cast<String, dynamic>()),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<BindingDevice>> getOnlineDevices() async {
|
||||
final data = await _get('/api/client/devices/online');
|
||||
final list = (data['devices'] as List?) ?? [];
|
||||
return [
|
||||
for (final e in list)
|
||||
if (e is Map)
|
||||
BindingDevice.fromJson(e.cast<String, dynamic>()),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> redeemPairingCode(String code) async {
|
||||
await _post(
|
||||
'/api/client/pairing/redeem',
|
||||
body: {'code': code},
|
||||
auth: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||||
try {
|
||||
return await _get('/api/client/turn-credentials');
|
||||
} on ApiException {
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -121,24 +150,61 @@ class DioAuthRepository implements AuthRepository {
|
||||
Future<Map<String, dynamic>> _post(
|
||||
String path, {
|
||||
required Map<String, dynamic> body,
|
||||
bool auth = false,
|
||||
}) async {
|
||||
final res = await _dio.post<dynamic>(
|
||||
return _handle(
|
||||
await _send(
|
||||
() => _dio.post<dynamic>(
|
||||
path,
|
||||
data: jsonEncode(body),
|
||||
options: Options(
|
||||
headers: {
|
||||
if (auth && _accessToken != null)
|
||||
'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(String path) async {
|
||||
final res = await _dio.get<dynamic>(
|
||||
return _handle(
|
||||
await _send(
|
||||
() => _dio.get<dynamic>(
|
||||
path,
|
||||
options: Options(
|
||||
headers: {
|
||||
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
|
||||
if (_accessToken != null)
|
||||
'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
/// 发送请求,遇 401 时尝试用 refreshToken 续期后重试一次。
|
||||
///
|
||||
/// Dio 默认对非 2xx 直接抛出 [DioException](bad response),不会走到
|
||||
/// [_handle],因此在异常层拦截 401 并触发刷新重试,避免已登录用户因
|
||||
/// accessToken 过期而直接失败。
|
||||
Future<Response<dynamic>> _send(
|
||||
Future<Response<dynamic>> Function() request,
|
||||
) async {
|
||||
try {
|
||||
return await request();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 401 && await hasRefreshToken()) {
|
||||
try {
|
||||
await refresh();
|
||||
} on Object {
|
||||
rethrow;
|
||||
}
|
||||
return await request();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handle(Response<dynamic> res) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import '../../connection/domain/binding_device.dart';
|
||||
|
||||
/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。
|
||||
abstract interface class AuthRepository {
|
||||
/// 内存中的 accessToken(掉线即失)。
|
||||
@@ -19,7 +21,14 @@ abstract interface class AuthRepository {
|
||||
|
||||
Future<Map<String, dynamic>> verify();
|
||||
|
||||
Future<Map<String, dynamic>> bindings();
|
||||
/// 获取主控端已绑定的被控设备列表(含名称、鉴权方式等)。
|
||||
Future<List<BindingDevice>> getBindings();
|
||||
|
||||
/// 获取已绑定且当前在线(信令网络活跃)的设备列表。
|
||||
Future<List<BindingDevice>> getOnlineDevices();
|
||||
|
||||
/// 使用被控端展示的一次性配对码建立绑定关系。
|
||||
Future<void> redeemPairingCode(String code);
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。
|
||||
Future<Map<String, dynamic>?> turnCredentials();
|
||||
|
||||
@@ -14,20 +14,31 @@ import 'webrtc_controller.dart';
|
||||
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
|
||||
class RemoteController {
|
||||
final String serverUrl;
|
||||
final String targetDeviceId;
|
||||
final AuthRepository authRepository;
|
||||
String? token;
|
||||
|
||||
/// 目标被控端设备ID,在发起远程控制时指定。
|
||||
String? targetDeviceId;
|
||||
|
||||
String? authType;
|
||||
String? authValue;
|
||||
|
||||
late final SignalingClient _signaling;
|
||||
SignalingClient? _signaling;
|
||||
WebRtcController? _webRtc;
|
||||
Timer? _statsTimer;
|
||||
|
||||
/// 本地设备ID(由服务端 REGISTER_SUCCESS 下发)。
|
||||
String? _myDeviceId;
|
||||
|
||||
/// 信令是否已注册成功(拿到本机 deviceId)。
|
||||
bool get registered => _myDeviceId != null;
|
||||
|
||||
/// 本机设备ID(未注册时为 null)。
|
||||
String? get myDeviceId => _myDeviceId;
|
||||
|
||||
/// 是否已发起远程控制(WebRTC 会话存在)。
|
||||
bool get controlling => _webRtc != null;
|
||||
|
||||
/// 信令状态变化(如"正在连接…"、"已连接…)。
|
||||
void Function(String status)? onStatusChanged;
|
||||
|
||||
@@ -80,20 +91,23 @@ class RemoteController {
|
||||
/// 强制下线(4003):用于跳回登录。
|
||||
void Function()? onForceLogout;
|
||||
|
||||
/// 信令注册成功(下发本机 deviceId),此时可发起远程控制。
|
||||
void Function(String myDeviceId)? onRegistered;
|
||||
|
||||
RemoteController({
|
||||
required this.serverUrl,
|
||||
required this.targetDeviceId,
|
||||
required this.authRepository,
|
||||
this.targetDeviceId,
|
||||
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;
|
||||
/// 仅建立信令连接:确保 accessToken 后携带 Bearer 建立 WebSocket,
|
||||
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 即完成,**不会**发起远程控制。
|
||||
///
|
||||
/// 发起远程控制请在注册成功后调用 [startControl]。
|
||||
Future<void> connectSignaling() async {
|
||||
onStatusChanged?.call('状态: 正在连接信令服务器...');
|
||||
|
||||
try {
|
||||
@@ -104,20 +118,22 @@ class RemoteController {
|
||||
return;
|
||||
}
|
||||
|
||||
_signaling = SignalingClient(serverUrl: serverUrl, token: token);
|
||||
_signaling.onConnected = () {
|
||||
final signaling = SignalingClient(serverUrl: serverUrl, token: token);
|
||||
_signaling = signaling;
|
||||
signaling.onConnected = () {
|
||||
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
|
||||
};
|
||||
_signaling.onMessage = _handleSignalMessage;
|
||||
_signaling.onDisconnected = () {
|
||||
signaling.onMessage = _handleSignalMessage;
|
||||
signaling.onDisconnected = () {
|
||||
_myDeviceId = null;
|
||||
onStatusChanged?.call('状态: 已断开连接');
|
||||
onDisconnected?.call();
|
||||
};
|
||||
_signaling.onError = (error) {
|
||||
signaling.onError = (error) {
|
||||
onStatusChanged?.call('状态: 连接错误 - $error');
|
||||
onConnectionFailed?.call(error);
|
||||
};
|
||||
_signaling.onTokenExpired = () async {
|
||||
signaling.onTokenExpired = () async {
|
||||
try {
|
||||
await authRepository.refresh();
|
||||
token = authRepository.accessToken;
|
||||
@@ -127,11 +143,38 @@ class RemoteController {
|
||||
onTokenExpired?.call();
|
||||
}
|
||||
};
|
||||
_signaling.onForceLogout = () {
|
||||
signaling.onForceLogout = () {
|
||||
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
|
||||
onForceLogout?.call();
|
||||
};
|
||||
_signaling.connect();
|
||||
signaling.connect();
|
||||
}
|
||||
|
||||
/// 发起远程控制:在信令注册成功后调用,建立 WebRTC 并向目标设备创建 Offer。
|
||||
///
|
||||
/// [target] 目标被控端设备ID;[authType]/[authValue] 为被控端鉴权方式。
|
||||
void startControl({
|
||||
required String target,
|
||||
String? authType,
|
||||
String? authValue,
|
||||
}) {
|
||||
if (!registered) {
|
||||
onConnectionFailed?.call('信令未就绪,请稍后重试');
|
||||
return;
|
||||
}
|
||||
if (_webRtc != null) return;
|
||||
targetDeviceId = target;
|
||||
this.authType = authType;
|
||||
this.authValue = authValue;
|
||||
_initWebRtc();
|
||||
}
|
||||
|
||||
/// 停止远程控制但保持信令连接(便于重新选择设备发起连接)。
|
||||
Future<void> stopControl() async {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
await _webRtc?.close();
|
||||
_webRtc = null;
|
||||
}
|
||||
|
||||
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
|
||||
@@ -150,20 +193,22 @@ class RemoteController {
|
||||
}
|
||||
|
||||
void _reconnect() {
|
||||
_signaling.disconnect();
|
||||
_signaling.connect();
|
||||
_signaling?.disconnect();
|
||||
_signaling?.connect();
|
||||
}
|
||||
|
||||
void _initWebRtc() {
|
||||
final deviceId = _myDeviceId;
|
||||
if (deviceId == null) {
|
||||
final signaling = _signaling;
|
||||
final target = targetDeviceId;
|
||||
if (deviceId == null || signaling == null || target == null) {
|
||||
onStatusChanged?.call('状态: 未获取到本机设备ID,连接中止');
|
||||
return;
|
||||
}
|
||||
_webRtc = WebRtcController(
|
||||
signaling: _signaling,
|
||||
signaling: signaling,
|
||||
deviceId: deviceId,
|
||||
targetDeviceId: targetDeviceId,
|
||||
targetDeviceId: target,
|
||||
authType: authType,
|
||||
authValue: authValue,
|
||||
);
|
||||
@@ -195,10 +240,10 @@ class RemoteController {
|
||||
final type = message.type?.toUpperCase();
|
||||
if (type == 'REGISTER_SUCCESS') {
|
||||
// 服务端下发本机 deviceId,作为后续 OFFER 的 fromDeviceId。
|
||||
_myDeviceId = message.fromDeviceId;
|
||||
_initWebRtc();
|
||||
// 拉取可连接的被控端绑定列表(仅已绑定设备)。
|
||||
_loadBindings();
|
||||
final deviceId = message.fromDeviceId;
|
||||
_myDeviceId = deviceId;
|
||||
onStatusChanged?.call('状态: 已连接信令服务器 ($deviceId)');
|
||||
if (deviceId != null) onRegistered?.call(deviceId);
|
||||
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
|
||||
_loadTurnCredentials();
|
||||
return;
|
||||
@@ -239,27 +284,6 @@ class RemoteController {
|
||||
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();
|
||||
@@ -297,12 +321,11 @@ class RemoteController {
|
||||
Future<void> sendStreamMode(int mode) =>
|
||||
_webRtc?.sendStreamMode(mode) ?? Future.value();
|
||||
|
||||
/// 断开连接并释放资源。
|
||||
/// 断开连接并释放资源(同时关闭 WebRTC 与信令)。
|
||||
Future<void> disconnect() async {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
await _webRtc?.close();
|
||||
_webRtc = null;
|
||||
_signaling.disconnect();
|
||||
await stopControl();
|
||||
_signaling?.disconnect();
|
||||
_signaling = null;
|
||||
_myDeviceId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ class WebRtcController {
|
||||
/// 通过 DataChannel 发送控制指令(protobuf 二进制)。
|
||||
void sendControlCommand(ControlMessage command) {
|
||||
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||
debugPrint('WebRtcController: Sending command -> ${command.action}');
|
||||
debugPrint('WebRtcController: Sending command -> ${command.action} keyCode ${command.keyCode}');
|
||||
_dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'binding_device.g.dart';
|
||||
|
||||
/// 主控端已绑定的被控设备(对应服务端 DeviceBinding / 在线设备列表)。
|
||||
///
|
||||
/// 字段对齐 [WebRTCSignalServer] 的 [DeviceBinding] 与 OnDeviceDto,
|
||||
/// 以及 Android 控制端的 [BindingItem],供用户在列表中选取并连接。
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class BindingDevice {
|
||||
const BindingDevice({
|
||||
required this.deviceUid,
|
||||
this.name,
|
||||
this.online = false,
|
||||
this.authType = 'NONE',
|
||||
this.lastSeen,
|
||||
});
|
||||
|
||||
/// 被控端设备唯一标识(连接信令时使用)。
|
||||
///
|
||||
/// 服务端 [DeviceBinding.deviceUid](必返回,缺失时兜底空串以保证列表可用)。
|
||||
@JsonKey(name: 'deviceUid')
|
||||
final String deviceUid;
|
||||
|
||||
/// 设备展示名称,对应服务端 [DeviceBinding.alias],可为 null。
|
||||
///
|
||||
/// 未设置别名时回退为设备标识,避免空名称影响展示与选择。
|
||||
@JsonKey(name: 'alias')
|
||||
final String? name;
|
||||
|
||||
/// 是否在线(信令网络中活跃)。
|
||||
final bool online;
|
||||
|
||||
/// 连接鉴权方式:NONE / PASSWORD / CODE。
|
||||
///
|
||||
/// 服务端字段为 [DeviceBinding.authType],缺失时默认 NONE。
|
||||
@JsonKey(name: 'authType', defaultValue: 'NONE')
|
||||
final String authType;
|
||||
|
||||
/// 最近一次在线时间戳(毫秒)。
|
||||
@JsonKey(name: 'lastSeen')
|
||||
final int? lastSeen;
|
||||
|
||||
factory BindingDevice.fromJson(Map<String, dynamic> json) =>
|
||||
_$BindingDeviceFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BindingDeviceToJson(this);
|
||||
|
||||
BindingDevice copyWith({
|
||||
String? deviceUid,
|
||||
String? name,
|
||||
bool? online,
|
||||
String? authType,
|
||||
int? lastSeen,
|
||||
}) =>
|
||||
BindingDevice(
|
||||
deviceUid: deviceUid ?? this.deviceUid,
|
||||
name: name ?? this.name,
|
||||
online: online ?? this.online,
|
||||
authType: authType ?? this.authType,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'binding_device.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
BindingDevice _$BindingDeviceFromJson(Map<String, dynamic> json) =>
|
||||
BindingDevice(
|
||||
deviceUid: json['deviceUid'] as String? ?? '',
|
||||
name: json['alias'] as String? ?? json['deviceUid'] as String? ?? '',
|
||||
online: json['online'] as bool? ?? false,
|
||||
authType: json['authType'] as String? ?? 'NONE',
|
||||
lastSeen: json['lastSeen'] as int?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BindingDeviceToJson(BindingDevice instance) =>
|
||||
<String, dynamic>{
|
||||
'deviceUid': instance.deviceUid,
|
||||
'alias': instance.name,
|
||||
'online': instance.online,
|
||||
'authType': instance.authType,
|
||||
'lastSeen': instance.lastSeen,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../app/constants/app_constants.dart';
|
||||
import 'binding_device.dart';
|
||||
|
||||
part 'connection_session_state.freezed.dart';
|
||||
|
||||
@@ -15,9 +16,36 @@ class ConnectionSessionState with _$ConnectionSessionState {
|
||||
/// 是否正在连接信令服务器 / 建立 WebRTC。
|
||||
@Default(false) bool connecting,
|
||||
|
||||
/// 信令(WebSocket)是否已连接并注册成功。
|
||||
@Default(false) bool signalingConnected,
|
||||
|
||||
/// 信令注册后服务端下发的本机设备ID。
|
||||
String? myDeviceId,
|
||||
|
||||
/// 状态提示文本。
|
||||
@Default('') String status,
|
||||
|
||||
/// 主控端已绑定的被控设备列表(供用户选择连接)。
|
||||
@Default(<BindingDevice>[]) List<BindingDevice> bindings,
|
||||
|
||||
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
|
||||
@Default(<String>{}) Set<String> onlineUids,
|
||||
|
||||
/// 用户在绑定设备列表中选中的目标设备 uid。
|
||||
String? selectedDeviceUid,
|
||||
|
||||
/// 是否正在拉取绑定设备列表 / 在线状态。
|
||||
@Default(false) bool deviceRefreshing,
|
||||
|
||||
/// 设备列表加载失败时的错误提示(null 表示无错误)。
|
||||
String? deviceError,
|
||||
|
||||
/// 是否正在兑换配对码建立绑定。
|
||||
@Default(false) bool redeeming,
|
||||
|
||||
/// 配对码兑换失败提示(null 表示无错误)。
|
||||
String? redeemError,
|
||||
|
||||
/// 连接统计文本(每秒刷新)。
|
||||
@Default('') String stats,
|
||||
|
||||
@@ -68,5 +96,5 @@ class ConnectionSessionState with _$ConnectionSessionState {
|
||||
|
||||
/// 一次性提示消息(消费后由控制器置空)。
|
||||
String? alert,
|
||||
}) = _ConnectionSessionState;
|
||||
}) = _$ConnectionSessionStateImpl;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// 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, unnecessary_null_comparison
|
||||
|
||||
part of 'connection_session_state.dart';
|
||||
|
||||
@@ -15,66 +15,132 @@ 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',
|
||||
);
|
||||
|
||||
ConnectionSessionState _$ConnectionSessionStateFromJson(Map<String, dynamic> json) {
|
||||
return ConnectionSessionState(
|
||||
connected: json['connected'] as bool? ?? false,
|
||||
connecting: json['connecting'] as bool? ?? false,
|
||||
signalingConnected: json['signalingConnected'] as bool? ?? false,
|
||||
myDeviceId: json['myDeviceId'] as String?,
|
||||
status: json['status'] as String? ?? '',
|
||||
bindings: (json['bindings'] as List?)?.cast<BindingDevice>() ?? const [],
|
||||
onlineUids: (json['onlineUids'] as Set?)?.cast<String>() ?? const {},
|
||||
selectedDeviceUid: json['selectedDeviceUid'] as String?,
|
||||
deviceRefreshing: json['deviceRefreshing'] as bool? ?? false,
|
||||
deviceError: json['deviceError'] as String?,
|
||||
redeeming: json['redeeming'] as bool? ?? false,
|
||||
redeemError: json['redeemError'] as String?,
|
||||
stats: json['stats'] as String? ?? '',
|
||||
videoAspect: json['videoAspect'] as double? ?? 16 / 9,
|
||||
streamMode: json['streamMode'] as int? ?? 0,
|
||||
selfCodecTextureId: json['selfCodecTextureId'] as int?,
|
||||
selfCodecReady: json['selfCodecReady'] as bool? ?? false,
|
||||
selfCodecSupported: json['selfCodecSupported'] as bool? ?? true,
|
||||
recording: json['recording'] as bool? ?? false,
|
||||
recordStatus: json['recordStatus'] as String? ?? '',
|
||||
pendingRecordStart: json['pendingRecordStart'] as bool? ?? false,
|
||||
selectedResolution: json['selectedResolution'] as int? ?? 0,
|
||||
resolutionOptions: (json['resolutionOptions'] as List?)?.cast<Map<String, Object>>() ?? kResolutionOptions,
|
||||
fpsOptions: (json['fpsOptions'] as List?)?.cast<int>() ?? kDefaultFpsOptions,
|
||||
currentFps: json['currentFps'] as int? ?? 0,
|
||||
lastReportedWidth: json['lastReportedWidth'] as int? ?? 0,
|
||||
lastReportedHeight: json['lastReportedHeight'] as int? ?? 0,
|
||||
renderer: json['renderer'] as RTCVideoRenderer?,
|
||||
alert: json['alert'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ConnectionSessionStateToJson(ConnectionSessionState instance) {
|
||||
final val = <String, dynamic>{};
|
||||
val['connected'] = instance.connected;
|
||||
val['connecting'] = instance.connecting;
|
||||
val['signalingConnected'] = instance.signalingConnected;
|
||||
val['myDeviceId'] = instance.myDeviceId;
|
||||
val['status'] = instance.status;
|
||||
val['bindings'] = instance.bindings;
|
||||
val['onlineUids'] = instance.onlineUids;
|
||||
val['selectedDeviceUid'] = instance.selectedDeviceUid;
|
||||
val['deviceRefreshing'] = instance.deviceRefreshing;
|
||||
val['deviceError'] = instance.deviceError;
|
||||
val['redeeming'] = instance.redeeming;
|
||||
val['redeemError'] = instance.redeemError;
|
||||
val['stats'] = instance.stats;
|
||||
val['videoAspect'] = instance.videoAspect;
|
||||
val['streamMode'] = instance.streamMode;
|
||||
val['selfCodecTextureId'] = instance.selfCodecTextureId;
|
||||
val['selfCodecReady'] = instance.selfCodecReady;
|
||||
val['selfCodecSupported'] = instance.selfCodecSupported;
|
||||
val['recording'] = instance.recording;
|
||||
val['recordStatus'] = instance.recordStatus;
|
||||
val['pendingRecordStart'] = instance.pendingRecordStart;
|
||||
val['selectedResolution'] = instance.selectedResolution;
|
||||
val['resolutionOptions'] = instance.resolutionOptions;
|
||||
val['fpsOptions'] = instance.fpsOptions;
|
||||
val['currentFps'] = instance.currentFps;
|
||||
val['lastReportedWidth'] = instance.lastReportedWidth;
|
||||
val['lastReportedHeight'] = instance.lastReportedHeight;
|
||||
val['renderer'] = instance.renderer;
|
||||
val['alert'] = instance.alert;
|
||||
return val;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ConnectionSessionState {
|
||||
/// 是否已建立 WebRTC 连接(可开始远程控制)。
|
||||
bool get connected => throw _privateConstructorUsedError;
|
||||
|
||||
/// 是否正在连接信令服务器 / 建立 WebRTC。
|
||||
bool get connecting => throw _privateConstructorUsedError;
|
||||
|
||||
/// 信令(WebSocket)是否已连接并注册成功。
|
||||
bool get signalingConnected => throw _privateConstructorUsedError;
|
||||
/// 信令注册后服务端下发的本机设备ID。
|
||||
String? get myDeviceId => throw _privateConstructorUsedError;
|
||||
/// 状态提示文本。
|
||||
String get status => throw _privateConstructorUsedError;
|
||||
|
||||
/// 主控端已绑定的被控设备列表(供用户选择连接)。
|
||||
List<BindingDevice> get bindings => throw _privateConstructorUsedError;
|
||||
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
|
||||
Set<String> get onlineUids => throw _privateConstructorUsedError;
|
||||
/// 用户在绑定设备列表中选中的目标设备 uid。
|
||||
String? get selectedDeviceUid => throw _privateConstructorUsedError;
|
||||
/// 是否正在拉取绑定设备列表 / 在线状态。
|
||||
bool get deviceRefreshing => throw _privateConstructorUsedError;
|
||||
/// 设备列表加载失败时的错误提示(null 表示无错误)。
|
||||
String? get deviceError => throw _privateConstructorUsedError;
|
||||
/// 是否正在兑换配对码建立绑定。
|
||||
bool get redeeming => throw _privateConstructorUsedError;
|
||||
/// 配对码兑换失败提示(null 表示无错误)。
|
||||
String? get redeemError => 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<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;
|
||||
|
||||
@@ -95,7 +161,16 @@ abstract class $ConnectionSessionStateCopyWith<$Res> {
|
||||
$Res call({
|
||||
bool connected,
|
||||
bool connecting,
|
||||
bool signalingConnected,
|
||||
String? myDeviceId,
|
||||
String status,
|
||||
List<BindingDevice> bindings,
|
||||
Set<String> onlineUids,
|
||||
String? selectedDeviceUid,
|
||||
bool deviceRefreshing,
|
||||
String? deviceError,
|
||||
bool redeeming,
|
||||
String? redeemError,
|
||||
String stats,
|
||||
double videoAspect,
|
||||
int streamMode,
|
||||
@@ -129,14 +204,21 @@ class _$ConnectionSessionStateCopyWithImpl<
|
||||
// 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? signalingConnected = null,
|
||||
Object? myDeviceId = freezed,
|
||||
Object? status = null,
|
||||
Object? bindings = null,
|
||||
Object? onlineUids = null,
|
||||
Object? selectedDeviceUid = freezed,
|
||||
Object? deviceRefreshing = null,
|
||||
Object? deviceError = freezed,
|
||||
Object? redeeming = null,
|
||||
Object? redeemError = freezed,
|
||||
Object? stats = null,
|
||||
Object? videoAspect = null,
|
||||
Object? streamMode = null,
|
||||
@@ -165,10 +247,46 @@ class _$ConnectionSessionStateCopyWithImpl<
|
||||
? _value.connecting
|
||||
: connecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
signalingConnected: null == signalingConnected
|
||||
? _value.signalingConnected
|
||||
: signalingConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
myDeviceId: freezed == myDeviceId
|
||||
? _value.myDeviceId
|
||||
: myDeviceId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
bindings: null == bindings
|
||||
? _value.bindings
|
||||
: bindings // ignore: cast_nullable_to_non_nullable
|
||||
as List<BindingDevice>,
|
||||
onlineUids: null == onlineUids
|
||||
? _value.onlineUids
|
||||
: onlineUids // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,
|
||||
selectedDeviceUid: freezed == selectedDeviceUid
|
||||
? _value.selectedDeviceUid
|
||||
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
deviceRefreshing: null == deviceRefreshing
|
||||
? _value.deviceRefreshing
|
||||
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
deviceError: freezed == deviceError
|
||||
? _value.deviceError
|
||||
: deviceError // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
redeeming: null == redeeming
|
||||
? _value.redeeming
|
||||
: redeeming // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
redeemError: freezed == redeemError
|
||||
? _value.redeemError
|
||||
: redeemError // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
stats: null == stats
|
||||
? _value.stats
|
||||
: stats // ignore: cast_nullable_to_non_nullable
|
||||
@@ -237,12 +355,219 @@ class _$ConnectionSessionStateCopyWithImpl<
|
||||
? _value.alert
|
||||
: alert // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
) as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ConnectionSessionStateImpl with _$ConnectionSessionState
|
||||
implements ConnectionSessionState {
|
||||
const _$ConnectionSessionStateImpl({
|
||||
this.connected = false,
|
||||
this.connecting = false,
|
||||
this.signalingConnected = false,
|
||||
this.myDeviceId,
|
||||
this.status = '',
|
||||
this.bindings = const [],
|
||||
this.onlineUids = const {},
|
||||
this.selectedDeviceUid,
|
||||
this.deviceRefreshing = false,
|
||||
this.deviceError,
|
||||
this.redeeming = false,
|
||||
this.redeemError,
|
||||
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,
|
||||
this.resolutionOptions = kResolutionOptions,
|
||||
this.fpsOptions = kDefaultFpsOptions,
|
||||
this.currentFps = 0,
|
||||
this.lastReportedWidth = 0,
|
||||
this.lastReportedHeight = 0,
|
||||
this.renderer,
|
||||
this.alert,
|
||||
});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool connected;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool connecting;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool signalingConnected;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String? myDeviceId;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String status;
|
||||
@override
|
||||
@JsonKey()
|
||||
final List<BindingDevice> bindings;
|
||||
@override
|
||||
@JsonKey()
|
||||
final Set<String> onlineUids;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String? selectedDeviceUid;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool deviceRefreshing;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String? deviceError;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool redeeming;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String? redeemError;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String stats;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double videoAspect;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int streamMode;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int? selfCodecTextureId;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool selfCodecReady;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool selfCodecSupported;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool recording;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String recordStatus;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool pendingRecordStart;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int selectedResolution;
|
||||
@override
|
||||
@JsonKey()
|
||||
final List<Map<String, Object>> resolutionOptions;
|
||||
@override
|
||||
@JsonKey()
|
||||
final List<int> fpsOptions;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int currentFps;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int lastReportedWidth;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int lastReportedHeight;
|
||||
@override
|
||||
@JsonKey()
|
||||
final RTCVideoRenderer? renderer;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String? alert;
|
||||
|
||||
@override
|
||||
$ConnectionSessionStateCopyWith<ConnectionSessionState> get copyWith =>
|
||||
__$$ConnectionSessionStateImplCopyWithImpl<ConnectionSessionState>(
|
||||
this,
|
||||
(value) => value,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ConnectionSessionStateToJson(this);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ConnectionSessionState(${[
|
||||
if (connected != null) 'connected: $connected',
|
||||
if (connecting != null) 'connecting: $connecting',
|
||||
if (signalingConnected != null) 'signalingConnected: $signalingConnected',
|
||||
if (myDeviceId != null) 'myDeviceId: $myDeviceId',
|
||||
if (status != null) 'status: $status',
|
||||
if (bindings != null) 'bindings: $bindings',
|
||||
if (onlineUids != null) 'onlineUids: $onlineUids',
|
||||
if (selectedDeviceUid != null) 'selectedDeviceUid: $selectedDeviceUid',
|
||||
if (deviceRefreshing != null) 'deviceRefreshing: $deviceRefreshing',
|
||||
if (deviceError != null) 'deviceError: $deviceError',
|
||||
if (redeeming != null) 'redeeming: $redeeming',
|
||||
if (redeemError != null) 'redeemError: $redeemError',
|
||||
if (stats != null) 'stats: $stats',
|
||||
if (videoAspect != null) 'videoAspect: $videoAspect',
|
||||
if (streamMode != null) 'streamMode: $streamMode',
|
||||
if (selfCodecTextureId != null) 'selfCodecTextureId: $selfCodecTextureId',
|
||||
if (selfCodecReady != null) 'selfCodecReady: $selfCodecReady',
|
||||
if (selfCodecSupported != null) 'selfCodecSupported: $selfCodecSupported',
|
||||
if (recording != null) 'recording: $recording',
|
||||
if (recordStatus != null) 'recordStatus: $recordStatus',
|
||||
if (pendingRecordStart != null) 'pendingRecordStart: $pendingRecordStart',
|
||||
if (selectedResolution != null) 'selectedResolution: $selectedResolution',
|
||||
if (resolutionOptions != null) 'resolutionOptions: $resolutionOptions',
|
||||
if (fpsOptions != null) 'fpsOptions: $fpsOptions',
|
||||
if (currentFps != null) 'currentFps: $currentFps',
|
||||
if (lastReportedWidth != null) 'lastReportedWidth: $lastReportedWidth',
|
||||
if (lastReportedHeight != null) 'lastReportedHeight: $lastReportedHeight',
|
||||
if (renderer != null) 'renderer: $renderer',
|
||||
if (alert != null) 'alert: $alert',
|
||||
].where((t) => t != null).join(', ')})';
|
||||
}
|
||||
|
||||
@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.signalingConnected, signalingConnected) || other.signalingConnected == signalingConnected)
|
||||
&& (identical(other.myDeviceId, myDeviceId) || other.myDeviceId == myDeviceId)
|
||||
&& (identical(other.status, status) || other.status == status)
|
||||
&& (identical(other.bindings, bindings) || other.bindings == bindings)
|
||||
&& (identical(other.onlineUids, onlineUids) || other.onlineUids == onlineUids)
|
||||
&& (identical(other.selectedDeviceUid, selectedDeviceUid) || other.selectedDeviceUid == selectedDeviceUid)
|
||||
&& (identical(other.deviceRefreshing, deviceRefreshing) || other.deviceRefreshing == deviceRefreshing)
|
||||
&& (identical(other.deviceError, deviceError) || other.deviceError == deviceError)
|
||||
&& (identical(other.redeeming, redeeming) || other.redeeming == redeeming)
|
||||
&& (identical(other.redeemError, redeemError) || other.redeemError == redeemError)
|
||||
&& (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)
|
||||
&& (identical(other.resolutionOptions, resolutionOptions) || other.resolutionOptions == resolutionOptions)
|
||||
&& (identical(other.fpsOptions, fpsOptions) || 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 => connected.hashCode ^ connecting.hashCode ^ signalingConnected.hashCode ^ myDeviceId.hashCode ^ status.hashCode ^ bindings.hashCode ^ onlineUids.hashCode ^ selectedDeviceUid.hashCode ^ deviceRefreshing.hashCode ^ deviceError.hashCode ^ redeeming.hashCode ^ redeemError.hashCode ^ stats.hashCode ^ videoAspect.hashCode ^ streamMode.hashCode ^ selfCodecTextureId.hashCode ^ selfCodecReady.hashCode ^ selfCodecSupported.hashCode ^ recording.hashCode ^ recordStatus.hashCode ^ pendingRecordStart.hashCode ^ selectedResolution.hashCode ^ resolutionOptions.hashCode ^ fpsOptions.hashCode ^ currentFps.hashCode ^ lastReportedWidth.hashCode ^ lastReportedHeight.hashCode ^ renderer.hashCode ^ alert.hashCode;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ConnectionSessionStateImplCopyWith<$Res>
|
||||
implements $ConnectionSessionStateCopyWith<$Res> {
|
||||
@@ -255,7 +580,16 @@ abstract class _$$ConnectionSessionStateImplCopyWith<$Res>
|
||||
$Res call({
|
||||
bool connected,
|
||||
bool connecting,
|
||||
bool signalingConnected,
|
||||
String? myDeviceId,
|
||||
String status,
|
||||
List<BindingDevice> bindings,
|
||||
Set<String> onlineUids,
|
||||
String? selectedDeviceUid,
|
||||
bool deviceRefreshing,
|
||||
String? deviceError,
|
||||
bool redeeming,
|
||||
String? redeemError,
|
||||
String stats,
|
||||
double videoAspect,
|
||||
int streamMode,
|
||||
@@ -286,14 +620,21 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
|
||||
$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? signalingConnected = null,
|
||||
Object? myDeviceId = freezed,
|
||||
Object? status = null,
|
||||
Object? bindings = null,
|
||||
Object? onlineUids = null,
|
||||
Object? selectedDeviceUid = freezed,
|
||||
Object? deviceRefreshing = null,
|
||||
Object? deviceError = freezed,
|
||||
Object? redeeming = null,
|
||||
Object? redeemError = freezed,
|
||||
Object? stats = null,
|
||||
Object? videoAspect = null,
|
||||
Object? streamMode = null,
|
||||
@@ -322,10 +663,46 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
|
||||
? _value.connecting
|
||||
: connecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
signalingConnected: null == signalingConnected
|
||||
? _value.signalingConnected
|
||||
: signalingConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
myDeviceId: freezed == myDeviceId
|
||||
? _value.myDeviceId
|
||||
: myDeviceId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
bindings: null == bindings
|
||||
? _value.bindings
|
||||
: bindings // ignore: cast_nullable_to_non_nullable
|
||||
as List<BindingDevice>,
|
||||
onlineUids: null == onlineUids
|
||||
? _value.onlineUids
|
||||
: onlineUids // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,
|
||||
selectedDeviceUid: freezed == selectedDeviceUid
|
||||
? _value.selectedDeviceUid
|
||||
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
deviceRefreshing: null == deviceRefreshing
|
||||
? _value.deviceRefreshing
|
||||
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
deviceError: freezed == deviceError
|
||||
? _value.deviceError
|
||||
: deviceError // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
redeeming: null == redeeming
|
||||
? _value.redeeming
|
||||
: redeeming // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
redeemError: freezed == redeemError
|
||||
? _value.redeemError
|
||||
: redeemError // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
stats: null == stats
|
||||
? _value.stats
|
||||
: stats // ignore: cast_nullable_to_non_nullable
|
||||
@@ -367,11 +744,11 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
|
||||
: selectedResolution // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resolutionOptions: null == resolutionOptions
|
||||
? _value._resolutionOptions
|
||||
? _value.resolutionOptions
|
||||
: resolutionOptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, Object>>,
|
||||
fpsOptions: null == fpsOptions
|
||||
? _value._fpsOptions
|
||||
? _value.fpsOptions
|
||||
: fpsOptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
currentFps: null == currentFps
|
||||
@@ -399,344 +776,3 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
|
||||
}
|
||||
}
|
||||
|
||||
/// @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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../auth/data/auth_providers.dart';
|
||||
import '../data/remote_controller.dart';
|
||||
import '../data/self_codec_decoder.dart';
|
||||
import '../data/video_recorder.dart';
|
||||
import '../domain/binding_device.dart';
|
||||
import '../domain/connection_session_state.dart';
|
||||
|
||||
part 'connection_controller.g.dart';
|
||||
@@ -40,32 +41,159 @@ class ConnectionController extends _$ConnectionController {
|
||||
|
||||
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');
|
||||
/// 仅建立信令(WebSocket)连接,**不**发起远程控制。
|
||||
///
|
||||
/// 进入设置页即可调用;重复调用会被忽略。
|
||||
Future<void> connectSignaling({required String serverUrl}) async {
|
||||
if (serverUrl.isEmpty) {
|
||||
_alert('信令服务器地址为空');
|
||||
return;
|
||||
}
|
||||
if (_remote != null || state.signalingConnected) 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);
|
||||
await _remote!.connectSignaling();
|
||||
}
|
||||
|
||||
/// 发起远程控制:需信令已就绪,向目标设备创建 Offer。
|
||||
Future<void> startControl({
|
||||
required String targetDeviceId,
|
||||
required String authType,
|
||||
required String authValue,
|
||||
}) async {
|
||||
if (targetDeviceId.isEmpty) {
|
||||
_alert('请填写目标设备ID');
|
||||
return;
|
||||
}
|
||||
if (!state.signalingConnected || _remote == null) {
|
||||
_alert('信令未就绪,请稍后重试');
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(connecting: true, status: '状态: 正在发起连接...');
|
||||
_remote!.startControl(
|
||||
target: targetDeviceId,
|
||||
authType: authType,
|
||||
authValue: authValue,
|
||||
);
|
||||
}
|
||||
|
||||
/// 拉取已绑定设备列表,并合并在线设备状态。
|
||||
///
|
||||
/// 参考 Android 控制端的 [MainViewModel.loadBindings] / [getOnlineDevices],
|
||||
/// 用在线列表标记绑定设备是否可立即连接。
|
||||
Future<void> loadDevices() async {
|
||||
final authRepository = ref.read(authRepositoryProvider);
|
||||
state = state.copyWith(deviceRefreshing: true, deviceError: null);
|
||||
try {
|
||||
final bindings = await authRepository.getBindings();
|
||||
List<BindingDevice> online = const [];
|
||||
try {
|
||||
online = await authRepository.getOnlineDevices();
|
||||
} on Object {
|
||||
// 在线列表接口失败时,仍展示全部绑定(仅不标记在线)。
|
||||
}
|
||||
final onlineUids = {for (final d in online) d.deviceUid};
|
||||
final merged = [
|
||||
for (final b in bindings)
|
||||
b.copyWith(online: onlineUids.contains(b.deviceUid)),
|
||||
];
|
||||
final stillSelected = merged
|
||||
.where((b) => b.deviceUid == state.selectedDeviceUid)
|
||||
.isNotEmpty;
|
||||
state = state.copyWith(
|
||||
bindings: merged,
|
||||
onlineUids: onlineUids,
|
||||
deviceRefreshing: false,
|
||||
selectedDeviceUid: stillSelected ? state.selectedDeviceUid : null,
|
||||
);
|
||||
} on Object catch (e) {
|
||||
debugPrint(e.toString());
|
||||
state = state.copyWith(
|
||||
deviceRefreshing: false,
|
||||
deviceError: '加载绑定设备失败:$e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 在绑定设备列表中选择一个目标设备(仅记录选择,不下发连接)。
|
||||
void selectDevice(String uid) {
|
||||
if (state.selectedDeviceUid == uid) return;
|
||||
state = state.copyWith(selectedDeviceUid: uid);
|
||||
}
|
||||
|
||||
/// 当前选中的绑定设备(无则返回 null)。
|
||||
BindingDevice? get selectedDevice {
|
||||
final uid = state.selectedDeviceUid;
|
||||
if (uid == null) return null;
|
||||
for (final b in state.bindings) {
|
||||
if (b.deviceUid == uid) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 向选中的绑定设备发起远程控制。
|
||||
///
|
||||
/// 鉴权方式取自 [BindingDevice.authType],凭据由 UI 的鉴权对话框收集后传入。
|
||||
Future<void> connectSelectedDevice({
|
||||
required String authType,
|
||||
required String authValue,
|
||||
}) async {
|
||||
final target = selectedDevice;
|
||||
if (target == null) {
|
||||
_alert('请先选择一个绑定的设备');
|
||||
return;
|
||||
}
|
||||
if (!target.online) {
|
||||
_alert('设备「${target.name}」当前不在线,无法连接');
|
||||
return;
|
||||
}
|
||||
await startControl(
|
||||
targetDeviceId: target.deviceUid,
|
||||
authType: authType,
|
||||
authValue: authValue,
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用被控端展示的配对码建立绑定关系,并刷新绑定设备列表。
|
||||
Future<bool> redeemPairingCode(String code) async {
|
||||
final trimmed = code.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
state = state.copyWith(redeemError: '请输入配对码');
|
||||
return false;
|
||||
}
|
||||
final authRepository = ref.read(authRepositoryProvider);
|
||||
state = state.copyWith(redeeming: true, redeemError: null);
|
||||
try {
|
||||
await authRepository.redeemPairingCode(trimmed);
|
||||
state = state.copyWith(redeeming: false);
|
||||
await loadDevices();
|
||||
return true;
|
||||
} on Object catch (e) {
|
||||
state = state.copyWith(redeeming: false, redeemError: '绑定失败:$e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除配对码兑换错误提示。
|
||||
void clearRedeemError() => state.copyWith(redeemError: null);
|
||||
|
||||
void _wireRemoteCallbacks() {
|
||||
final remote = _remote!;
|
||||
remote.onStatusChanged = _setStatus;
|
||||
remote.onRegistered = (myDeviceId) {
|
||||
state = state.copyWith(
|
||||
signalingConnected: true,
|
||||
connecting: false,
|
||||
myDeviceId: myDeviceId,
|
||||
);
|
||||
};
|
||||
remote.onConnectionEstablished = () {
|
||||
state = state.copyWith(
|
||||
connected: true,
|
||||
@@ -78,20 +206,26 @@ class ConnectionController extends _$ConnectionController {
|
||||
_alert('连接失败:$error');
|
||||
};
|
||||
remote.onDisconnected = () {
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
state = state.copyWith(
|
||||
connected: false,
|
||||
connecting: false,
|
||||
signalingConnected: false,
|
||||
myDeviceId: null,
|
||||
);
|
||||
_setStatus('状态: 远端已断开');
|
||||
};
|
||||
// 以下三种情况仅结束远程控制,保留信令连接以便重新发起。
|
||||
remote.onIceDisconnected = (message) {
|
||||
_alert(message);
|
||||
disconnect();
|
||||
stopControl();
|
||||
};
|
||||
remote.onTargetOffline = (message) {
|
||||
_alert(message);
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
stopControl();
|
||||
};
|
||||
remote.onConnectionRejected = (message) {
|
||||
_alert(message);
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
stopControl();
|
||||
};
|
||||
remote.onRemoteStream = (renderer) {
|
||||
_renderer = renderer;
|
||||
@@ -114,7 +248,8 @@ class ConnectionController extends _$ConnectionController {
|
||||
};
|
||||
remote.onStreamModeReport = (mode) {
|
||||
state = state.copyWith(streamMode: mode);
|
||||
if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) {
|
||||
if (mode == SelfCodecDecoder.streamModeWebRtc &&
|
||||
state.pendingRecordStart) {
|
||||
state = state.copyWith(pendingRecordStart: false);
|
||||
_startRecording();
|
||||
}
|
||||
@@ -261,7 +396,23 @@ class ConnectionController extends _$ConnectionController {
|
||||
_remote?.sendControlCommand(command);
|
||||
}
|
||||
|
||||
/// 断开连接并复位全部 UI 状态。
|
||||
/// 结束远程控制但保持信令连接,便于重新选择设备发起连接。
|
||||
Future<void> stopControl() async {
|
||||
await _videoRecorder.dispose();
|
||||
await _remote?.stopControl();
|
||||
_renderer?.removeListener(_onRendererUpdate);
|
||||
_renderer = null;
|
||||
_lastResizedAspect = 0;
|
||||
final signalingAlive = state.signalingConnected;
|
||||
state = ConnectionSessionState(
|
||||
status: signalingAlive ? '状态: 已连接信令服务器' : '状态: 已停止',
|
||||
signalingConnected: signalingAlive,
|
||||
myDeviceId: state.myDeviceId,
|
||||
alert: state.alert,
|
||||
);
|
||||
}
|
||||
|
||||
/// 断开全部连接(含信令)并复位 UI 状态。
|
||||
Future<void> disconnect() async {
|
||||
await _videoRecorder.dispose();
|
||||
await _remote?.disconnect();
|
||||
@@ -269,7 +420,7 @@ class ConnectionController extends _$ConnectionController {
|
||||
_renderer?.removeListener(_onRendererUpdate);
|
||||
_renderer = null;
|
||||
_lastResizedAspect = 0;
|
||||
state = const ConnectionSessionState(status: '状态: 已停止');
|
||||
state = ConnectionSessionState(status: '状态: 已停止', alert: state.alert);
|
||||
}
|
||||
|
||||
/// 消费一次性提示消息(UI 展示后调用)。
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'connection_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$connectionControllerHash() =>
|
||||
r'bea162831e84bb80f7921d7c774d76e07969b023';
|
||||
r'83ae35ab7dbb3a7d23106106040e9146f1dc6782';
|
||||
|
||||
/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。
|
||||
///
|
||||
|
||||
@@ -266,7 +266,8 @@ class _ControlPageState extends ConsumerState<ControlPage> {
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () async {
|
||||
await ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
// 仅结束远程控制,保留信令连接以便返回设置页后直接重连。
|
||||
await ref.read(connectionControllerProvider.notifier).stopControl();
|
||||
if (mounted) context.go('/');
|
||||
},
|
||||
child: const Icon(
|
||||
|
||||
@@ -6,11 +6,12 @@ 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/binding_device.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});
|
||||
|
||||
@@ -19,32 +20,73 @@ class SetupPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
final _serverUrlController = TextEditingController(
|
||||
text: kDefaultSignalServer,
|
||||
);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// build() 期间不能改动 provider 状态,推迟到首帧之后。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _autoConnectSignaling());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_targetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 进入页面后自动建立信令(WebSocket)连接,并拉取绑定设备列表。
|
||||
///
|
||||
/// 仅连接信令并完成注册,**不会**向任何设备发起远程控制;
|
||||
/// 能进入本页即已登录,携带 Bearer token 即可建连。
|
||||
Future<void> _autoConnectSignaling() async {
|
||||
if (!mounted) return;
|
||||
final loggedIn =
|
||||
ref.read(authControllerProvider).valueOrNull?.loggedIn ?? false;
|
||||
if (!loggedIn) return;
|
||||
|
||||
await ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.connectSignaling(serverUrl: kDefaultSignalServer);
|
||||
if (!mounted) return;
|
||||
await ref.read(connectionControllerProvider.notifier).loadDevices();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState = ref.watch(authControllerProvider).valueOrNull;
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
// 连接建立后跳转控制页。
|
||||
// 登录完成后补发信令连接;退出登录则断开信令。
|
||||
ref.listen(authControllerProvider, (prev, next) {
|
||||
final was = prev?.valueOrNull?.loggedIn ?? false;
|
||||
final now = next.valueOrNull?.loggedIn ?? false;
|
||||
if (!was && now) {
|
||||
_autoConnectSignaling();
|
||||
} else if (was && !now) {
|
||||
ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 信令注册成功后回显服务端下发的本机设备ID。
|
||||
final myDeviceId = connection.myDeviceId ?? '';
|
||||
if (_deviceIdController.text != myDeviceId) {
|
||||
_deviceIdController.text = myDeviceId;
|
||||
}
|
||||
|
||||
// 连接建立后跳转控制页;连接失败等一次性提示展示后立即消费。
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if ((prev?.connected ?? false) == false && next.connected) {
|
||||
context.go('/control');
|
||||
return;
|
||||
}
|
||||
final alert = next.alert;
|
||||
if (alert != null && alert.isNotEmpty) {
|
||||
ref.read(connectionControllerProvider.notifier).consumeAlert();
|
||||
_showAlert(alert);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -59,25 +101,6 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
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(
|
||||
@@ -89,18 +112,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
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),
|
||||
_buildDeviceSection(context, l10n, connection),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connection.status,
|
||||
style: const TextStyle(
|
||||
@@ -108,6 +122,13 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (connection.deviceError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
connection.deviceError!,
|
||||
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -129,7 +150,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
|
||||
(authState?.loggedIn ?? false)
|
||||
? l10n.logout
|
||||
: l10n.loginAccount,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -150,12 +173,142 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。
|
||||
Widget _buildDeviceSection(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ConnectionSessionState connection,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(l10n.boundDevices, style: const TextStyle(fontSize: 16)),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: connection.deviceRefreshing
|
||||
? null
|
||||
: () => ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.loadDevices(),
|
||||
child: connection.deviceRefreshing
|
||||
? const CupertinoActivityIndicator(radius: 9)
|
||||
: Text(l10n.refreshDevices),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (connection.bindings.isEmpty)
|
||||
_buildEmptyHint(context, l10n, connection)
|
||||
else
|
||||
_DeviceList(
|
||||
devices: connection.bindings,
|
||||
selectedUid: connection.selectedDeviceUid,
|
||||
onSelect: (uid) => ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.selectDevice(uid),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: connection.redeeming
|
||||
? null
|
||||
: () => _showRedeemDialog(context, l10n),
|
||||
child: connection.redeeming
|
||||
? const CupertinoActivityIndicator()
|
||||
: Text(l10n.redeemPairingCode),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 无绑定设备时的引导提示(含配对失败提示)。
|
||||
Widget _buildEmptyHint(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ConnectionSessionState connection,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.noBoundDevices,
|
||||
style: const TextStyle(fontSize: 14, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
if (connection.redeemError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
connection.redeemError!,
|
||||
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 兑换配对码对话框。
|
||||
Future<void> _showRedeemDialog(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: Text(l10n.redeemPairingCode),
|
||||
content: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: l10n.pairingCodeHint,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.cancel),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.confirm),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) {
|
||||
controller.dispose();
|
||||
return;
|
||||
}
|
||||
final code = controller.text.trim();
|
||||
// 延迟到下一帧再释放,避免对话框卸载时焦点回调访问已释放的 controller。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
|
||||
if (code.isEmpty) return;
|
||||
final success = await ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.redeemPairingCode(code);
|
||||
if (success && mounted) {
|
||||
_showAlert(l10n.bindSuccess);
|
||||
} else if (mounted) {
|
||||
final err = ref.read(connectionControllerProvider).redeemError;
|
||||
if (err != null && err.isNotEmpty) _showAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
final selected =
|
||||
ref.read(connectionControllerProvider.notifier).selectedDevice;
|
||||
if (selected == null) {
|
||||
_showAlert(l10n.pleaseSelectDevice);
|
||||
return;
|
||||
}
|
||||
if (!selected.online) {
|
||||
_showAlert(l10n.deviceOfflineCannotConnect(selected.name ?? selected.deviceUid));
|
||||
return;
|
||||
}
|
||||
// 连接前确保已登录(Bearer token)。
|
||||
@@ -169,12 +322,16 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
|
||||
// 信令若尚未就绪(如刚登录或曾断开),先补建连接。
|
||||
if (!ref.read(connectionControllerProvider).signalingConnected) {
|
||||
await _autoConnectSignaling();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
final selection = await showAuthDialog(context);
|
||||
if (selection == null || !mounted) return;
|
||||
|
||||
await ref.read(connectionControllerProvider.notifier).connect(
|
||||
serverUrl: serverUrl,
|
||||
targetDeviceId: target,
|
||||
await ref.read(connectionControllerProvider.notifier).connectSelectedDevice(
|
||||
authType: selection.type,
|
||||
authValue: selection.value,
|
||||
);
|
||||
@@ -195,3 +352,38 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定设备选择列表(Cupertino 风格)。
|
||||
class _DeviceList extends StatelessWidget {
|
||||
const _DeviceList({
|
||||
required this.devices,
|
||||
required this.selectedUid,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<BindingDevice> devices;
|
||||
final String? selectedUid;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return CupertinoListSection.insetGrouped(
|
||||
margin: EdgeInsets.zero,
|
||||
children: [
|
||||
for (final d in devices)
|
||||
CupertinoListTile(
|
||||
title: Text(d.name ?? d.deviceUid),
|
||||
subtitle: Text(d.online ? l10n.online : l10n.offline),
|
||||
trailing: selectedUid == d.deviceUid
|
||||
? const Icon(
|
||||
CupertinoIcons.check_mark_circled_solid,
|
||||
color: CupertinoColors.activeBlue,
|
||||
)
|
||||
: null,
|
||||
onTap: () => onSelect(d.deviceUid),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"connecting": "Connecting...",
|
||||
"loginAccount": "Login Account",
|
||||
"connectDevice": "Connect Device",
|
||||
"serverUrl": "Signal Server URL:",
|
||||
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
|
||||
|
||||
"deviceId": "Device ID (assigned by server):",
|
||||
"deviceIdPlaceholder": "Assigned by server",
|
||||
"targetDeviceId": "Target Device ID:",
|
||||
@@ -23,7 +22,7 @@
|
||||
"password": "Password",
|
||||
"loginFailed": "Login failed: {error}",
|
||||
"usernamePasswordRequired": "Please enter username and password",
|
||||
"serverAndTargetRequired": "Please enter server URL and target device ID",
|
||||
"targetRequired": "Please enter target device ID",
|
||||
"authTitle": "Connection Auth",
|
||||
"authNone": "Passwordless",
|
||||
"authNoneDesc": "Manual confirmation on the controlled device",
|
||||
@@ -67,5 +66,16 @@
|
||||
"forceLogout": "Account logged in elsewhere, force logged out.",
|
||||
"loginFirst": "Please login before connecting",
|
||||
"pleaseFillAuth": "Please enter the code or password",
|
||||
"streamModeSelfCodecDesc": "Self-codec hardware decoding is not supported on this platform."
|
||||
"streamModeSelfCodecDesc": "Self-codec hardware decoding is not supported on this platform.",
|
||||
"boundDevices": "Bound Devices",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"noBoundDevices": "No bound devices yet",
|
||||
"refreshDevices": "Refresh",
|
||||
"redeemPairingCode": "Bind with Pairing Code",
|
||||
"pairingCodeHint": "Enter the pairing code shown on the controlled device",
|
||||
"bindingFailed": "Binding failed: {error}",
|
||||
"deviceOfflineCannotConnect": "Device \"{name}\" is currently offline and cannot be connected",
|
||||
"pleaseSelectDevice": "Please select a bound device first",
|
||||
"bindSuccess": "Bound successfully"
|
||||
}
|
||||
|
||||
@@ -176,18 +176,6 @@ abstract class AppLocalizations {
|
||||
/// **'连接被控设备'**
|
||||
String get connectDevice;
|
||||
|
||||
/// No description provided for @serverUrl.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'信令服务器地址:'**
|
||||
String get serverUrl;
|
||||
|
||||
/// No description provided for @serverUrlPlaceholder.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'wss://www.ttstd.com/signal'**
|
||||
String get serverUrlPlaceholder;
|
||||
|
||||
/// No description provided for @deviceId.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -236,11 +224,11 @@ abstract class AppLocalizations {
|
||||
/// **'请输入用户名和密码'**
|
||||
String get usernamePasswordRequired;
|
||||
|
||||
/// No description provided for @serverAndTargetRequired.
|
||||
/// No description provided for @targetRequired.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请填写服务器地址和目标设备ID'**
|
||||
String get serverAndTargetRequired;
|
||||
/// **'请填写目标设备ID'**
|
||||
String get targetRequired;
|
||||
|
||||
/// No description provided for @authTitle.
|
||||
///
|
||||
@@ -505,6 +493,72 @@ abstract class AppLocalizations {
|
||||
/// In zh, this message translates to:
|
||||
/// **'当前平台不支持自编码硬解。'**
|
||||
String get streamModeSelfCodecDesc;
|
||||
|
||||
/// No description provided for @boundDevices.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已绑定设备'**
|
||||
String get boundDevices;
|
||||
|
||||
/// No description provided for @online.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'在线'**
|
||||
String get online;
|
||||
|
||||
/// No description provided for @offline.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'离线'**
|
||||
String get offline;
|
||||
|
||||
/// No description provided for @noBoundDevices.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'暂无已绑定设备'**
|
||||
String get noBoundDevices;
|
||||
|
||||
/// No description provided for @refreshDevices.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'刷新'**
|
||||
String get refreshDevices;
|
||||
|
||||
/// No description provided for @redeemPairingCode.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'使用配对码绑定'**
|
||||
String get redeemPairingCode;
|
||||
|
||||
/// No description provided for @pairingCodeHint.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入被控端显示的配对码'**
|
||||
String get pairingCodeHint;
|
||||
|
||||
/// No description provided for @bindingFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'绑定失败:{error}'**
|
||||
String bindingFailed(Object error);
|
||||
|
||||
/// No description provided for @deviceOfflineCannotConnect.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设备「{name}」当前不在线,无法连接'**
|
||||
String deviceOfflineCannotConnect(Object name);
|
||||
|
||||
/// No description provided for @pleaseSelectDevice.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请先选择一个绑定的设备'**
|
||||
String get pleaseSelectDevice;
|
||||
|
||||
/// No description provided for @bindSuccess.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'绑定成功'**
|
||||
String get bindSuccess;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -49,12 +49,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get connectDevice => 'Connect Device';
|
||||
|
||||
@override
|
||||
String get serverUrl => 'Signal Server URL:';
|
||||
|
||||
@override
|
||||
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
|
||||
|
||||
@override
|
||||
String get deviceId => 'Device ID (assigned by server):';
|
||||
|
||||
@@ -82,8 +76,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get usernamePasswordRequired => 'Please enter username and password';
|
||||
|
||||
@override
|
||||
String get serverAndTargetRequired =>
|
||||
'Please enter server URL and target device ID';
|
||||
String get targetRequired => 'Please enter target device ID';
|
||||
|
||||
@override
|
||||
String get authTitle => 'Connection Auth';
|
||||
@@ -239,4 +232,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get streamModeSelfCodecDesc =>
|
||||
'Self-codec hardware decoding is not supported on this platform.';
|
||||
|
||||
@override
|
||||
String get boundDevices => 'Bound Devices';
|
||||
|
||||
@override
|
||||
String get online => 'Online';
|
||||
|
||||
@override
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get noBoundDevices => 'No bound devices yet';
|
||||
|
||||
@override
|
||||
String get refreshDevices => 'Refresh';
|
||||
|
||||
@override
|
||||
String get redeemPairingCode => 'Bind with Pairing Code';
|
||||
|
||||
@override
|
||||
String get pairingCodeHint =>
|
||||
'Enter the pairing code shown on the controlled device';
|
||||
|
||||
@override
|
||||
String bindingFailed(Object error) {
|
||||
return 'Binding failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String deviceOfflineCannotConnect(Object name) {
|
||||
return 'Device \"$name\" is currently offline and cannot be connected';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pleaseSelectDevice => 'Please select a bound device first';
|
||||
|
||||
@override
|
||||
String get bindSuccess => 'Bound successfully';
|
||||
}
|
||||
|
||||
@@ -49,12 +49,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get connectDevice => '连接被控设备';
|
||||
|
||||
@override
|
||||
String get serverUrl => '信令服务器地址:';
|
||||
|
||||
@override
|
||||
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
|
||||
|
||||
@override
|
||||
String get deviceId => '本机设备ID(连接后由服务端下发,无需填写):';
|
||||
|
||||
@@ -82,7 +76,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get usernamePasswordRequired => '请输入用户名和密码';
|
||||
|
||||
@override
|
||||
String get serverAndTargetRequired => '请填写服务器地址和目标设备ID';
|
||||
String get targetRequired => '请填写目标设备ID';
|
||||
|
||||
@override
|
||||
String get authTitle => '连接鉴权';
|
||||
@@ -231,4 +225,41 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get streamModeSelfCodecDesc => '当前平台不支持自编码硬解。';
|
||||
|
||||
@override
|
||||
String get boundDevices => '已绑定设备';
|
||||
|
||||
@override
|
||||
String get online => '在线';
|
||||
|
||||
@override
|
||||
String get offline => '离线';
|
||||
|
||||
@override
|
||||
String get noBoundDevices => '暂无已绑定设备';
|
||||
|
||||
@override
|
||||
String get refreshDevices => '刷新';
|
||||
|
||||
@override
|
||||
String get redeemPairingCode => '使用配对码绑定';
|
||||
|
||||
@override
|
||||
String get pairingCodeHint => '请输入被控端显示的配对码';
|
||||
|
||||
@override
|
||||
String bindingFailed(Object error) {
|
||||
return '绑定失败:$error';
|
||||
}
|
||||
|
||||
@override
|
||||
String deviceOfflineCannotConnect(Object name) {
|
||||
return '设备「$name」当前不在线,无法连接';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pleaseSelectDevice => '请先选择一个绑定的设备';
|
||||
|
||||
@override
|
||||
String get bindSuccess => '绑定成功';
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"connecting": "正在连接...",
|
||||
"loginAccount": "登录账号",
|
||||
"connectDevice": "连接被控设备",
|
||||
"serverUrl": "信令服务器地址:",
|
||||
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
|
||||
|
||||
"deviceId": "本机设备ID(连接后由服务端下发,无需填写):",
|
||||
"deviceIdPlaceholder": "连接后由服务端下发",
|
||||
"targetDeviceId": "目标被控设备ID:",
|
||||
@@ -23,7 +22,7 @@
|
||||
"password": "密码",
|
||||
"loginFailed": "登录失败:{error}",
|
||||
"usernamePasswordRequired": "请输入用户名和密码",
|
||||
"serverAndTargetRequired": "请填写服务器地址和目标设备ID",
|
||||
"targetRequired": "请填写目标设备ID",
|
||||
"authTitle": "连接鉴权",
|
||||
"authNone": "免密连接",
|
||||
"authNoneDesc": "被控端弹出手动确认框,无需验证码或密码",
|
||||
@@ -67,5 +66,16 @@
|
||||
"forceLogout": "账号已在其他位置登录,已强制下线。",
|
||||
"loginFirst": "请先登录后再连接",
|
||||
"pleaseFillAuth": "请输入验证码或密码",
|
||||
"streamModeSelfCodecDesc": "当前平台不支持自编码硬解。"
|
||||
"streamModeSelfCodecDesc": "当前平台不支持自编码硬解。",
|
||||
"boundDevices": "已绑定设备",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"noBoundDevices": "暂无已绑定设备",
|
||||
"refreshDevices": "刷新",
|
||||
"redeemPairingCode": "使用配对码绑定",
|
||||
"pairingCodeHint": "请输入被控端显示的配对码",
|
||||
"bindingFailed": "绑定失败:{error}",
|
||||
"deviceOfflineCannotConnect": "设备「{name}」当前不在线,无法连接",
|
||||
"pleaseSelectDevice": "请先选择一个绑定的设备",
|
||||
"bindSuccess": "绑定成功"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 全局关闭键盘焦点高亮边框
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: WebrtcControllerApp(),
|
||||
|
||||
Reference in New Issue
Block a user