From e1de4c8170e44e836afa3db7fca4ce54d7f3e378 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 3 Aug 2026 20:22:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(remote):=20=E4=BF=AE=E5=A4=8D=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E8=BE=93=E5=85=A5=E6=98=A0=E5=B0=84=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=BF=9E=E6=8E=A5=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题 - Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发 - 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换 - AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法 - DioAuthRepository实现401自动刷新token重试机制 - ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段 --- .../controlled/input/SystemInputUtils.java | 5 + .../lib/core/utils/control_commands.dart | 59 + .../lib/core/widgets/remote_touch_view.dart | 43 +- .../auth/data/dio_auth_repository.dart | 92 +- .../features/auth/domain/auth_repository.dart | 11 +- .../connection/data/remote_controller.dart | 127 +- .../connection/data/webrtc_controller.dart | 2 +- .../connection/domain/binding_device.dart | 63 + .../connection/domain/binding_device.g.dart | 25 + .../domain/connection_session_state.dart | 30 +- .../connection_session_state.freezed.dart | 1084 +++++++++-------- .../presentation/connection_controller.dart | 187 ++- .../presentation/connection_controller.g.dart | 2 +- .../presentation/pages/control_page.dart | 3 +- .../presentation/pages/setup_page.dart | 284 ++++- webrtc_controller_flutter/lib/l10n/app_en.arb | 18 +- .../lib/l10n/app_localizations.dart | 84 +- .../lib/l10n/app_localizations_en.dart | 47 +- .../lib/l10n/app_localizations_zh.dart | 45 +- webrtc_controller_flutter/lib/l10n/app_zh.arb | 18 +- webrtc_controller_flutter/lib/main.dart | 3 + 21 files changed, 1529 insertions(+), 703 deletions(-) create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/binding_device.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/binding_device.g.dart diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/input/SystemInputUtils.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/input/SystemInputUtils.java index e24ea6a..fb5d55d 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/input/SystemInputUtils.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/input/SystemInputUtils.java @@ -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) { diff --git a/webrtc_controller_flutter/lib/core/utils/control_commands.dart b/webrtc_controller_flutter/lib/core/utils/control_commands.dart index 91ef1cd..aecde59 100644 --- a/webrtc_controller_flutter/lib/core/utils/control_commands.dart +++ b/webrtc_controller_flutter/lib/core/utils/control_commands.dart @@ -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.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, diff --git a/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart b/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart index 0fcdc98..916c7c1 100644 --- a/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart +++ b/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart @@ -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 { + /// 用于捕获物理键盘事件的焦点节点。 + /// 设置 [FocusNode.skipTraversal] 避免桌面端获得焦点时绘制一圈绿色高亮边框。 + final FocusNode _focusNode = FocusNode(skipTraversal: true); + Offset? _startPosition; Offset? _currentPosition; DateTime? _startTime; @@ -77,19 +83,41 @@ class _RemoteTouchViewState extends State { @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( - autofocus: true, - onKeyEvent: (node, event) { + 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 { child: SizedBox.expand(), ), ), - ); - } + ), + ); +} } diff --git a/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart b/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart index b2347bf..ea949f0 100644 --- a/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart +++ b/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart @@ -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> verify() => _get('/api/client/verify'); @override - Future> bindings() => _get('/api/client/bindings'); + Future> 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()), + ]; + } + + @override + Future> 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()), + ]; + } + + @override + Future redeemPairingCode(String code) async { + await _post( + '/api/client/pairing/redeem', + body: {'code': code}, + auth: true, + ); + } @override Future?> 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> _post( String path, { required Map body, + bool auth = false, }) async { - final res = await _dio.post( - path, - data: jsonEncode(body), + return _handle( + await _send( + () => _dio.post( + path, + data: jsonEncode(body), + options: Options( + headers: { + if (auth && _accessToken != null) + 'Authorization': 'Bearer $_accessToken', + }, + ), + ), + ), ); - return _handle(res); } Future> _get(String path) async { - final res = await _dio.get( - path, - options: Options( - headers: { - if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', - }, + return _handle( + await _send( + () => _dio.get( + path, + options: Options( + headers: { + if (_accessToken != null) + 'Authorization': 'Bearer $_accessToken', + }, + ), + ), ), ); - return _handle(res); + } + + /// 发送请求,遇 401 时尝试用 refreshToken 续期后重试一次。 + /// + /// Dio 默认对非 2xx 直接抛出 [DioException](bad response),不会走到 + /// [_handle],因此在异常层拦截 401 并触发刷新重试,避免已登录用户因 + /// accessToken 过期而直接失败。 + Future> _send( + Future> 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 _handle(Response res) { diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart index 8cdef58..947cf18 100644 --- a/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart @@ -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> verify(); - Future> bindings(); + /// 获取主控端已绑定的被控设备列表(含名称、鉴权方式等)。 + Future> getBindings(); + + /// 获取已绑定且当前在线(信令网络活跃)的设备列表。 + Future> getOnlineDevices(); + + /// 使用被控端展示的一次性配对码建立绑定关系。 + Future redeemPairingCode(String code); /// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。 Future?> turnCredentials(); diff --git a/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart b/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart index 1494950..c8adbac 100644 --- a/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart @@ -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 connect({String? authType, String? authValue}) async { - this.authType = authType; - this.authValue = authValue; + /// 仅建立信令连接:确保 accessToken 后携带 Bearer 建立 WebSocket, + /// 待 REGISTER_SUCCESS 拿到本机 deviceId 即完成,**不会**发起远程控制。 + /// + /// 发起远程控制请在注册成功后调用 [startControl]。 + Future 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 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 _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 _loadTurnCredentials() async { final data = await authRepository.turnCredentials(); @@ -297,12 +321,11 @@ class RemoteController { Future sendStreamMode(int mode) => _webRtc?.sendStreamMode(mode) ?? Future.value(); - /// 断开连接并释放资源。 + /// 断开连接并释放资源(同时关闭 WebRTC 与信令)。 Future disconnect() async { - _statsTimer?.cancel(); - _statsTimer = null; - await _webRtc?.close(); - _webRtc = null; - _signaling.disconnect(); + await stopControl(); + _signaling?.disconnect(); + _signaling = null; + _myDeviceId = null; } } diff --git a/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart b/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart index c1add18..492a3da 100644 --- a/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart @@ -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())); } } diff --git a/webrtc_controller_flutter/lib/features/connection/domain/binding_device.dart b/webrtc_controller_flutter/lib/features/connection/domain/binding_device.dart new file mode 100644 index 0000000..9be6a1a --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/binding_device.dart @@ -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 json) => + _$BindingDeviceFromJson(json); + + Map 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, + ); +} diff --git a/webrtc_controller_flutter/lib/features/connection/domain/binding_device.g.dart b/webrtc_controller_flutter/lib/features/connection/domain/binding_device.g.dart new file mode 100644 index 0000000..de4b0c7 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/binding_device.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'binding_device.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BindingDevice _$BindingDeviceFromJson(Map 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 _$BindingDeviceToJson(BindingDevice instance) => + { + 'deviceUid': instance.deviceUid, + 'alias': instance.name, + 'online': instance.online, + 'authType': instance.authType, + 'lastSeen': instance.lastSeen, + }; diff --git a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart index cba3ef4..2be4cf4 100644 --- a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart +++ b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart @@ -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([]) List bindings, + + /// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。 + @Default({}) Set 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; } diff --git a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart index aa76641..3eeba2a 100644 --- a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart +++ b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart @@ -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 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() ?? const [], + onlineUids: (json['onlineUids'] as Set?)?.cast() ?? 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>() ?? kResolutionOptions, + fpsOptions: (json['fpsOptions'] as List?)?.cast() ?? 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 _$ConnectionSessionStateToJson(ConnectionSessionState instance) { + final val = {}; + 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 get bindings => throw _privateConstructorUsedError; + /// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。 + Set 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> get resolutionOptions => - throw _privateConstructorUsedError; - + List> get resolutionOptions => throw _privateConstructorUsedError; /// 帧率档位(收到被控端上报后以上报列表为准)。 List 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 bindings, + Set 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, @@ -157,163 +239,6 @@ class _$ConnectionSessionStateCopyWithImpl< }) { 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>, - fpsOptions: null == fpsOptions - ? _value.fpsOptions - : fpsOptions // ignore: cast_nullable_to_non_nullable - as List, - 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> resolutionOptions, - List 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 @@ -322,10 +247,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, + onlineUids: null == onlineUids + ? _value.onlineUids + : onlineUids // ignore: cast_nullable_to_non_nullable + as Set, + 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 +328,427 @@ 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>, fpsOptions: null == fpsOptions - ? _value._fpsOptions + ? _value.fpsOptions + : fpsOptions // ignore: cast_nullable_to_non_nullable + as List, + 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 +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 bindings; + @override + @JsonKey() + final Set 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> resolutionOptions; + @override + @JsonKey() + final List 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 get copyWith => + __$$ConnectionSessionStateImplCopyWithImpl( + this, + (value) => value, + ); + + Map 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> { + factory _$$ConnectionSessionStateImplCopyWith( + _$ConnectionSessionStateImpl value, + $Res Function(_$ConnectionSessionStateImpl) then, + ) = __$$ConnectionSessionStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + bool connected, + bool connecting, + bool signalingConnected, + String? myDeviceId, + String status, + List bindings, + Set onlineUids, + String? selectedDeviceUid, + bool deviceRefreshing, + String? deviceError, + bool redeeming, + String? redeemError, + String stats, + double videoAspect, + int streamMode, + int? selfCodecTextureId, + bool selfCodecReady, + bool selfCodecSupported, + bool recording, + String recordStatus, + bool pendingRecordStart, + int selectedResolution, + List> resolutionOptions, + List 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); + + @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, + 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, + 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, + onlineUids: null == onlineUids + ? _value.onlineUids + : onlineUids // ignore: cast_nullable_to_non_nullable + as Set, + 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 + 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>, + fpsOptions: null == fpsOptions + ? _value.fpsOptions : fpsOptions // ignore: cast_nullable_to_non_nullable as List, 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> resolutionOptions = kResolutionOptions, - final List 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> _resolutionOptions; - - /// 分辨率预设列表。 - @override - @JsonKey() - List> get resolutionOptions { - if (_resolutionOptions is EqualUnmodifiableListView) - return _resolutionOptions; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_resolutionOptions); - } - - /// 帧率档位(收到被控端上报后以上报列表为准)。 - final List _fpsOptions; - - /// 帧率档位(收到被控端上报后以上报列表为准)。 - @override - @JsonKey() - List 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> resolutionOptions, - final List 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> get resolutionOptions; - - /// 帧率档位(收到被控端上报后以上报列表为准)。 - @override - List 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; -} diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart index 2d4d8ea..4213b88 100644 --- a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart +++ b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart @@ -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 connect({ - required String serverUrl, - required String targetDeviceId, - required String authType, - required String authValue, - }) async { - if (serverUrl.isEmpty || targetDeviceId.isEmpty) { - _alert('请填写服务器地址和目标设备ID'); + /// 仅建立信令(WebSocket)连接,**不**发起远程控制。 + /// + /// 进入设置页即可调用;重复调用会被忽略。 + Future 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 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 loadDevices() async { + final authRepository = ref.read(authRepositoryProvider); + state = state.copyWith(deviceRefreshing: true, deviceError: null); + try { + final bindings = await authRepository.getBindings(); + List 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 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 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 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 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 展示后调用)。 diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart index fe206d6..771271d 100644 --- a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart +++ b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart @@ -7,7 +7,7 @@ part of 'connection_controller.dart'; // ************************************************************************** String _$connectionControllerHash() => - r'bea162831e84bb80f7921d7c774d76e07969b023'; + r'83ae35ab7dbb3a7d23106106040e9146f1dc6782'; /// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。 /// diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart b/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart index f0cdfa0..d05c836 100644 --- a/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart +++ b/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart @@ -266,7 +266,8 @@ class _ControlPageState extends ConsumerState { 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( diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart b/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart index 56d1d54..87268f8 100644 --- a/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart +++ b/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart @@ -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 { - 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 _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( 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 { 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 { 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 { 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 { } }, child: Text( - (authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount, + (authState?.loggedIn ?? false) + ? l10n.logout + : l10n.loginAccount, ), ), ), @@ -150,12 +173,142 @@ class _SetupPageState extends ConsumerState { ); } + /// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。 + 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 _showRedeemDialog( + BuildContext context, + AppLocalizations l10n, + ) async { + final controller = TextEditingController(); + final ok = await showCupertinoDialog( + 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 _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 { 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 { ); } } + +/// 绑定设备选择列表(Cupertino 风格)。 +class _DeviceList extends StatelessWidget { + const _DeviceList({ + required this.devices, + required this.selectedUid, + required this.onSelect, + }); + + final List devices; + final String? selectedUid; + final ValueChanged 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), + ), + ], + ); + } +} diff --git a/webrtc_controller_flutter/lib/l10n/app_en.arb b/webrtc_controller_flutter/lib/l10n/app_en.arb index 0c14e4c..e70961a 100644 --- a/webrtc_controller_flutter/lib/l10n/app_en.arb +++ b/webrtc_controller_flutter/lib/l10n/app_en.arb @@ -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" } diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations.dart b/webrtc_controller_flutter/lib/l10n/app_localizations.dart index cfcca7b..1a2a7a4 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations.dart @@ -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 diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart index f6182ed..4638107 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart index 99501f6..f2d0552 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart @@ -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 => '绑定成功'; } diff --git a/webrtc_controller_flutter/lib/l10n/app_zh.arb b/webrtc_controller_flutter/lib/l10n/app_zh.arb index 62706dc..b723a06 100644 --- a/webrtc_controller_flutter/lib/l10n/app_zh.arb +++ b/webrtc_controller_flutter/lib/l10n/app_zh.arb @@ -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": "绑定成功" } diff --git a/webrtc_controller_flutter/lib/main.dart b/webrtc_controller_flutter/lib/main.dart index 1b73b8d..54c438b 100644 --- a/webrtc_controller_flutter/lib/main.dart +++ b/webrtc_controller_flutter/lib/main.dart @@ -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(),