feat(remote): 修复键盘输入映射并重构连接流程

- Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题
- Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发
- 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换
- AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法
- DioAuthRepository实现401自动刷新token重试机制
- ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段
This commit is contained in:
TongTongStudio
2026-08-03 20:22:29 +08:00
parent 79cffcb041
commit e1de4c8170
21 changed files with 1529 additions and 703 deletions

View File

@@ -117,7 +117,12 @@ public class SystemInputUtils implements InputExecutor {
} }
private void injectKeyEvent(long downTime, long eventTime, int action, int keyCode) { 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); KeyEvent event = new KeyEvent(downTime, eventTime, action, keyCode, 0);
event.setSource(InputDevice.SOURCE_KEYBOARD);
try { try {
mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC); mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC);
} catch (Exception e) { } catch (Exception e) {

View File

@@ -1,4 +1,6 @@
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart'; import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。 /// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
@@ -8,6 +10,63 @@ import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
class ControlCommands { class ControlCommands {
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-ZAndroid 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-9Android KEYCODE_0=7 起
if (id >= 0x30 && id <= 0x39) return id - 0x30 + 7;
// 其余按键用 LogicalKeyboardKey 常量精确匹配,避免魔法数字。
// 注意LogicalKeyboardKey 重写了 ==/hashCode不能作为 const map 的 key
// 因此使用普通(非 constMap 字面量。
const home = LogicalKeyboardKey.home;
const end = LogicalKeyboardKey.end;
final map = <LogicalKeyboardKey, int>{
LogicalKeyboardKey.space: 62,
LogicalKeyboardKey.enter: 66,
LogicalKeyboardKey.numpadEnter: 66,
LogicalKeyboardKey.backspace: 67,
LogicalKeyboardKey.tab: 61,
LogicalKeyboardKey.escape: 111,
LogicalKeyboardKey.delete: 112,
home: 122,
end: 123,
LogicalKeyboardKey.pageUp: 92,
LogicalKeyboardKey.pageDown: 93,
LogicalKeyboardKey.arrowLeft: 21,
LogicalKeyboardKey.arrowUp: 19,
LogicalKeyboardKey.arrowRight: 22,
LogicalKeyboardKey.arrowDown: 20,
// 功能键 F1-F12Android 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)。 /// 单击指令。坐标 x/y 为相对于屏幕的百分比0.0 ~ 1.0)。
static ControlMessage touch(double x, double y) => ControlMessage( static ControlMessage touch(double x, double y) => ControlMessage(
action: Action.TOUCH, action: Action.TOUCH,

View File

@@ -4,6 +4,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/utils/control_commands.dart';
/// 远端触摸控制层(对应 Android 端 RemoteTouchView /// 远端触摸控制层(对应 Android 端 RemoteTouchView
/// ///
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标 /// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
@@ -43,6 +45,10 @@ class RemoteTouchView extends StatefulWidget {
} }
class _RemoteTouchViewState extends State<RemoteTouchView> { class _RemoteTouchViewState extends State<RemoteTouchView> {
/// 用于捕获物理键盘事件的焦点节点。
/// 设置 [FocusNode.skipTraversal] 避免桌面端获得焦点时绘制一圈绿色高亮边框。
final FocusNode _focusNode = FocusNode(skipTraversal: true);
Offset? _startPosition; Offset? _startPosition;
Offset? _currentPosition; Offset? _currentPosition;
DateTime? _startTime; DateTime? _startTime;
@@ -77,19 +83,41 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
@override @override
void dispose() { void dispose() {
_cancelLongPress(); _cancelLongPress();
_focusNode.dispose();
super.dispose(); super.dispose();
} }
@override
void initState() {
super.initState();
// 关闭框架的焦点高亮描边:本控件需要 autofocus 捕获物理键盘,但框架在
// 检测到键盘交互(传统高亮模式)后会给焦点节点绘制一圈绿色高亮边框。
// 将高亮策略设为 alwaysTouch 后边框不再绘制,键盘事件捕获不受影响。
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return Theme(
data: Theme.of(context).copyWith(
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Focus(
autofocus: true, autofocus: true,
focusNode: _focusNode,
onKeyEvent: (node, event) { onKeyEvent: (node, event) {
// 必须将 Flutter 的逻辑键转换为 Android keyCode 再下发,否则会被控端
// 误识别为功能键/组合键(屏幕乱跳、输入框无法输入文字)。
final int androidKeyCode = ControlCommands.toAndroidKeyCode(event.logicalKey);
if (androidKeyCode == 0) return KeyEventResult.ignored;
if (event is KeyDownEvent) { if (event is KeyDownEvent) {
widget.onKey(event.logicalKey.keyId, 0); widget.onKey(androidKeyCode, 0);
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (event is KeyUpEvent) { } else if (event is KeyUpEvent) {
widget.onKey(event.logicalKey.keyId, 1); widget.onKey(androidKeyCode, 1);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
@@ -177,6 +205,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
child: SizedBox.expand(), child: SizedBox.expand(),
), ),
), ),
),
); );
} }
} }

View File

@@ -6,6 +6,7 @@ import '../../../app/constants/app_constants.dart';
import '../../../core/network/api_exception.dart'; import '../../../core/network/api_exception.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import '../../connection/domain/binding_device.dart';
/// 基于 Dio 的 [AuthRepository] 实现。 /// 基于 Dio 的 [AuthRepository] 实现。
class DioAuthRepository implements AuthRepository { class DioAuthRepository implements AuthRepository {
@@ -101,13 +102,41 @@ class DioAuthRepository implements AuthRepository {
Future<Map<String, dynamic>> verify() => _get('/api/client/verify'); Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
@override @override
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings'); Future<List<BindingDevice>> getBindings() async {
final data = await _get('/api/client/bindings');
final list = (data['bindings'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<List<BindingDevice>> getOnlineDevices() async {
final data = await _get('/api/client/devices/online');
final list = (data['devices'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<void> redeemPairingCode(String code) async {
await _post(
'/api/client/pairing/redeem',
body: {'code': code},
auth: true,
);
}
@override @override
Future<Map<String, dynamic>?> turnCredentials() async { Future<Map<String, dynamic>?> turnCredentials() async {
try { try {
return await _get('/api/client/turn-credentials'); return await _get('/api/client/turn-credentials');
} on ApiException { } on Object {
return null; return null;
} }
} }
@@ -121,24 +150,61 @@ class DioAuthRepository implements AuthRepository {
Future<Map<String, dynamic>> _post( Future<Map<String, dynamic>> _post(
String path, { String path, {
required Map<String, dynamic> body, required Map<String, dynamic> body,
bool auth = false,
}) async { }) async {
final res = await _dio.post<dynamic>( return _handle(
await _send(
() => _dio.post<dynamic>(
path, path,
data: jsonEncode(body), data: jsonEncode(body),
options: Options(
headers: {
if (auth && _accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
); );
return _handle(res);
} }
Future<Map<String, dynamic>> _get(String path) async { Future<Map<String, dynamic>> _get(String path) async {
final res = await _dio.get<dynamic>( return _handle(
await _send(
() => _dio.get<dynamic>(
path, path,
options: Options( options: Options(
headers: { headers: {
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', if (_accessToken != null)
'Authorization': 'Bearer $_accessToken',
}, },
), ),
),
),
); );
return _handle(res); }
/// 发送请求,遇 401 时尝试用 refreshToken 续期后重试一次。
///
/// Dio 默认对非 2xx 直接抛出 [DioException]bad response不会走到
/// [_handle],因此在异常层拦截 401 并触发刷新重试,避免已登录用户因
/// accessToken 过期而直接失败。
Future<Response<dynamic>> _send(
Future<Response<dynamic>> Function() request,
) async {
try {
return await request();
} on DioException catch (e) {
if (e.response?.statusCode == 401 && await hasRefreshToken()) {
try {
await refresh();
} on Object {
rethrow;
}
return await request();
}
rethrow;
}
} }
Map<String, dynamic> _handle(Response<dynamic> res) { Map<String, dynamic> _handle(Response<dynamic> res) {

View File

@@ -1,3 +1,5 @@
import '../../connection/domain/binding_device.dart';
/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。 /// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。
abstract interface class AuthRepository { abstract interface class AuthRepository {
/// 内存中的 accessToken掉线即失 /// 内存中的 accessToken掉线即失
@@ -19,7 +21,14 @@ abstract interface class AuthRepository {
Future<Map<String, dynamic>> verify(); Future<Map<String, dynamic>> verify();
Future<Map<String, dynamic>> bindings(); /// 获取主控端已绑定的被控设备列表(含名称、鉴权方式等)。
Future<List<BindingDevice>> getBindings();
/// 获取已绑定且当前在线(信令网络活跃)的设备列表。
Future<List<BindingDevice>> getOnlineDevices();
/// 使用被控端展示的一次性配对码建立绑定关系。
Future<void> redeemPairingCode(String code);
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers关闭时返回 null。 /// 拉取 TURN 短期凭证(服务端开启时返回 iceServers关闭时返回 null。
Future<Map<String, dynamic>?> turnCredentials(); Future<Map<String, dynamic>?> turnCredentials();

View File

@@ -14,20 +14,31 @@ import 'webrtc_controller.dart';
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。 /// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
class RemoteController { class RemoteController {
final String serverUrl; final String serverUrl;
final String targetDeviceId;
final AuthRepository authRepository; final AuthRepository authRepository;
String? token; String? token;
/// 目标被控端设备ID在发起远程控制时指定。
String? targetDeviceId;
String? authType; String? authType;
String? authValue; String? authValue;
late final SignalingClient _signaling; SignalingClient? _signaling;
WebRtcController? _webRtc; WebRtcController? _webRtc;
Timer? _statsTimer; Timer? _statsTimer;
/// 本地设备ID由服务端 REGISTER_SUCCESS 下发)。 /// 本地设备ID由服务端 REGISTER_SUCCESS 下发)。
String? _myDeviceId; 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; void Function(String status)? onStatusChanged;
@@ -80,20 +91,23 @@ class RemoteController {
/// 强制下线4003用于跳回登录。 /// 强制下线4003用于跳回登录。
void Function()? onForceLogout; void Function()? onForceLogout;
/// 信令注册成功(下发本机 deviceId此时可发起远程控制。
void Function(String myDeviceId)? onRegistered;
RemoteController({ RemoteController({
required this.serverUrl, required this.serverUrl,
required this.targetDeviceId,
required this.authRepository, required this.authRepository,
this.targetDeviceId,
this.token, this.token,
this.authType, this.authType,
this.authValue, this.authValue,
}); });
/// 发起连接:确保 accessToken,成功后携带 Bearer 建立 WebSocket /// 仅建立信令连接:确保 accessToken 后携带 Bearer 建立 WebSocket
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 再建立 WebRTC 并创建 Offer /// 待 REGISTER_SUCCESS 拿到本机 deviceId 即完成,**不会**发起远程控制
Future<void> connect({String? authType, String? authValue}) async { ///
this.authType = authType; /// 发起远程控制请在注册成功后调用 [startControl]。
this.authValue = authValue; Future<void> connectSignaling() async {
onStatusChanged?.call('状态: 正在连接信令服务器...'); onStatusChanged?.call('状态: 正在连接信令服务器...');
try { try {
@@ -104,20 +118,22 @@ class RemoteController {
return; return;
} }
_signaling = SignalingClient(serverUrl: serverUrl, token: token); final signaling = SignalingClient(serverUrl: serverUrl, token: token);
_signaling.onConnected = () { _signaling = signaling;
signaling.onConnected = () {
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...'); onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
}; };
_signaling.onMessage = _handleSignalMessage; signaling.onMessage = _handleSignalMessage;
_signaling.onDisconnected = () { signaling.onDisconnected = () {
_myDeviceId = null;
onStatusChanged?.call('状态: 已断开连接'); onStatusChanged?.call('状态: 已断开连接');
onDisconnected?.call(); onDisconnected?.call();
}; };
_signaling.onError = (error) { signaling.onError = (error) {
onStatusChanged?.call('状态: 连接错误 - $error'); onStatusChanged?.call('状态: 连接错误 - $error');
onConnectionFailed?.call(error); onConnectionFailed?.call(error);
}; };
_signaling.onTokenExpired = () async { signaling.onTokenExpired = () async {
try { try {
await authRepository.refresh(); await authRepository.refresh();
token = authRepository.accessToken; token = authRepository.accessToken;
@@ -127,11 +143,38 @@ class RemoteController {
onTokenExpired?.call(); onTokenExpired?.call();
} }
}; };
_signaling.onForceLogout = () { signaling.onForceLogout = () {
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线'); onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
onForceLogout?.call(); onForceLogout?.call();
}; };
_signaling.connect(); signaling.connect();
}
/// 发起远程控制:在信令注册成功后调用,建立 WebRTC 并向目标设备创建 Offer。
///
/// [target] 目标被控端设备ID[authType]/[authValue] 为被控端鉴权方式。
void startControl({
required String target,
String? authType,
String? authValue,
}) {
if (!registered) {
onConnectionFailed?.call('信令未就绪,请稍后重试');
return;
}
if (_webRtc != null) return;
targetDeviceId = target;
this.authType = authType;
this.authValue = authValue;
_initWebRtc();
}
/// 停止远程控制但保持信令连接(便于重新选择设备发起连接)。
Future<void> stopControl() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
} }
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。 /// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
@@ -150,20 +193,22 @@ class RemoteController {
} }
void _reconnect() { void _reconnect() {
_signaling.disconnect(); _signaling?.disconnect();
_signaling.connect(); _signaling?.connect();
} }
void _initWebRtc() { void _initWebRtc() {
final deviceId = _myDeviceId; final deviceId = _myDeviceId;
if (deviceId == null) { final signaling = _signaling;
final target = targetDeviceId;
if (deviceId == null || signaling == null || target == null) {
onStatusChanged?.call('状态: 未获取到本机设备ID连接中止'); onStatusChanged?.call('状态: 未获取到本机设备ID连接中止');
return; return;
} }
_webRtc = WebRtcController( _webRtc = WebRtcController(
signaling: _signaling, signaling: signaling,
deviceId: deviceId, deviceId: deviceId,
targetDeviceId: targetDeviceId, targetDeviceId: target,
authType: authType, authType: authType,
authValue: authValue, authValue: authValue,
); );
@@ -195,10 +240,10 @@ class RemoteController {
final type = message.type?.toUpperCase(); final type = message.type?.toUpperCase();
if (type == 'REGISTER_SUCCESS') { if (type == 'REGISTER_SUCCESS') {
// 服务端下发本机 deviceId作为后续 OFFER 的 fromDeviceId。 // 服务端下发本机 deviceId作为后续 OFFER 的 fromDeviceId。
_myDeviceId = message.fromDeviceId; final deviceId = message.fromDeviceId;
_initWebRtc(); _myDeviceId = deviceId;
// 拉取可连接的被控端绑定列表(仅已绑定设备)。 onStatusChanged?.call('状态: 已连接信令服务器 ($deviceId)');
_loadBindings(); if (deviceId != null) onRegistered?.call(deviceId);
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。 // 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
_loadTurnCredentials(); _loadTurnCredentials();
return; return;
@@ -239,27 +284,6 @@ class RemoteController {
return payload; return payload;
} }
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。
Future<void> _loadBindings() async {
try {
final data = await authRepository.bindings();
final list = (data['bindings'] as List?) ?? [];
if (list.isNotEmpty) {
final uids = list.map((e) {
if (e is Map) {
return (e['deviceUid'] ?? e['deviceId'] ?? '').toString();
}
return e.toString();
}).where((s) => s.isNotEmpty).join(', ');
if (uids.isNotEmpty) {
onStatusChanged?.call('已绑定设备: $uids');
}
}
} catch (_) {
// 绑定列表拉取失败不影响主流程。
}
}
/// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。 /// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。
Future<void> _loadTurnCredentials() async { Future<void> _loadTurnCredentials() async {
final data = await authRepository.turnCredentials(); final data = await authRepository.turnCredentials();
@@ -297,12 +321,11 @@ class RemoteController {
Future<void> sendStreamMode(int mode) => Future<void> sendStreamMode(int mode) =>
_webRtc?.sendStreamMode(mode) ?? Future.value(); _webRtc?.sendStreamMode(mode) ?? Future.value();
/// 断开连接并释放资源。 /// 断开连接并释放资源(同时关闭 WebRTC 与信令)
Future<void> disconnect() async { Future<void> disconnect() async {
_statsTimer?.cancel(); await stopControl();
_statsTimer = null; _signaling?.disconnect();
await _webRtc?.close(); _signaling = null;
_webRtc = null; _myDeviceId = null;
_signaling.disconnect();
} }
} }

View File

@@ -501,7 +501,7 @@ class WebRtcController {
/// 通过 DataChannel 发送控制指令protobuf 二进制)。 /// 通过 DataChannel 发送控制指令protobuf 二进制)。
void sendControlCommand(ControlMessage command) { void sendControlCommand(ControlMessage command) {
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) { 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())); _dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
} }
} }

View File

@@ -0,0 +1,63 @@
import 'package:json_annotation/json_annotation.dart';
part 'binding_device.g.dart';
/// 主控端已绑定的被控设备(对应服务端 DeviceBinding / 在线设备列表)。
///
/// 字段对齐 [WebRTCSignalServer] 的 [DeviceBinding] 与 OnDeviceDto
/// 以及 Android 控制端的 [BindingItem],供用户在列表中选取并连接。
@JsonSerializable(fieldRename: FieldRename.snake)
class BindingDevice {
const BindingDevice({
required this.deviceUid,
this.name,
this.online = false,
this.authType = 'NONE',
this.lastSeen,
});
/// 被控端设备唯一标识(连接信令时使用)。
///
/// 服务端 [DeviceBinding.deviceUid](必返回,缺失时兜底空串以保证列表可用)。
@JsonKey(name: 'deviceUid')
final String deviceUid;
/// 设备展示名称,对应服务端 [DeviceBinding.alias],可为 null。
///
/// 未设置别名时回退为设备标识,避免空名称影响展示与选择。
@JsonKey(name: 'alias')
final String? name;
/// 是否在线(信令网络中活跃)。
final bool online;
/// 连接鉴权方式NONE / PASSWORD / CODE。
///
/// 服务端字段为 [DeviceBinding.authType],缺失时默认 NONE。
@JsonKey(name: 'authType', defaultValue: 'NONE')
final String authType;
/// 最近一次在线时间戳(毫秒)。
@JsonKey(name: 'lastSeen')
final int? lastSeen;
factory BindingDevice.fromJson(Map<String, dynamic> json) =>
_$BindingDeviceFromJson(json);
Map<String, dynamic> toJson() => _$BindingDeviceToJson(this);
BindingDevice copyWith({
String? deviceUid,
String? name,
bool? online,
String? authType,
int? lastSeen,
}) =>
BindingDevice(
deviceUid: deviceUid ?? this.deviceUid,
name: name ?? this.name,
online: online ?? this.online,
authType: authType ?? this.authType,
lastSeen: lastSeen ?? this.lastSeen,
);
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'binding_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BindingDevice _$BindingDeviceFromJson(Map<String, dynamic> json) =>
BindingDevice(
deviceUid: json['deviceUid'] as String? ?? '',
name: json['alias'] as String? ?? json['deviceUid'] as String? ?? '',
online: json['online'] as bool? ?? false,
authType: json['authType'] as String? ?? 'NONE',
lastSeen: json['lastSeen'] as int?,
);
Map<String, dynamic> _$BindingDeviceToJson(BindingDevice instance) =>
<String, dynamic>{
'deviceUid': instance.deviceUid,
'alias': instance.name,
'online': instance.online,
'authType': instance.authType,
'lastSeen': instance.lastSeen,
};

View File

@@ -2,6 +2,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../app/constants/app_constants.dart'; import '../../../app/constants/app_constants.dart';
import 'binding_device.dart';
part 'connection_session_state.freezed.dart'; part 'connection_session_state.freezed.dart';
@@ -15,9 +16,36 @@ class ConnectionSessionState with _$ConnectionSessionState {
/// 是否正在连接信令服务器 / 建立 WebRTC。 /// 是否正在连接信令服务器 / 建立 WebRTC。
@Default(false) bool connecting, @Default(false) bool connecting,
/// 信令WebSocket是否已连接并注册成功。
@Default(false) bool signalingConnected,
/// 信令注册后服务端下发的本机设备ID。
String? myDeviceId,
/// 状态提示文本。 /// 状态提示文本。
@Default('') String status, @Default('') String status,
/// 主控端已绑定的被控设备列表(供用户选择连接)。
@Default(<BindingDevice>[]) List<BindingDevice> bindings,
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
@Default(<String>{}) Set<String> onlineUids,
/// 用户在绑定设备列表中选中的目标设备 uid。
String? selectedDeviceUid,
/// 是否正在拉取绑定设备列表 / 在线状态。
@Default(false) bool deviceRefreshing,
/// 设备列表加载失败时的错误提示null 表示无错误)。
String? deviceError,
/// 是否正在兑换配对码建立绑定。
@Default(false) bool redeeming,
/// 配对码兑换失败提示null 表示无错误)。
String? redeemError,
/// 连接统计文本(每秒刷新)。 /// 连接统计文本(每秒刷新)。
@Default('') String stats, @Default('') String stats,
@@ -68,5 +96,5 @@ class ConnectionSessionState with _$ConnectionSessionState {
/// 一次性提示消息(消费后由控制器置空)。 /// 一次性提示消息(消费后由控制器置空)。
String? alert, String? alert,
}) = _ConnectionSessionState; }) = _$ConnectionSessionStateImpl;
} }

View File

@@ -1,7 +1,7 @@
// coverage:ignore-file // coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint // 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'; 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', 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
); );
ConnectionSessionState _$ConnectionSessionStateFromJson(Map<String, dynamic> json) {
return ConnectionSessionState(
connected: json['connected'] as bool? ?? false,
connecting: json['connecting'] as bool? ?? false,
signalingConnected: json['signalingConnected'] as bool? ?? false,
myDeviceId: json['myDeviceId'] as String?,
status: json['status'] as String? ?? '',
bindings: (json['bindings'] as List?)?.cast<BindingDevice>() ?? const [],
onlineUids: (json['onlineUids'] as Set?)?.cast<String>() ?? const {},
selectedDeviceUid: json['selectedDeviceUid'] as String?,
deviceRefreshing: json['deviceRefreshing'] as bool? ?? false,
deviceError: json['deviceError'] as String?,
redeeming: json['redeeming'] as bool? ?? false,
redeemError: json['redeemError'] as String?,
stats: json['stats'] as String? ?? '',
videoAspect: json['videoAspect'] as double? ?? 16 / 9,
streamMode: json['streamMode'] as int? ?? 0,
selfCodecTextureId: json['selfCodecTextureId'] as int?,
selfCodecReady: json['selfCodecReady'] as bool? ?? false,
selfCodecSupported: json['selfCodecSupported'] as bool? ?? true,
recording: json['recording'] as bool? ?? false,
recordStatus: json['recordStatus'] as String? ?? '',
pendingRecordStart: json['pendingRecordStart'] as bool? ?? false,
selectedResolution: json['selectedResolution'] as int? ?? 0,
resolutionOptions: (json['resolutionOptions'] as List?)?.cast<Map<String, Object>>() ?? kResolutionOptions,
fpsOptions: (json['fpsOptions'] as List?)?.cast<int>() ?? kDefaultFpsOptions,
currentFps: json['currentFps'] as int? ?? 0,
lastReportedWidth: json['lastReportedWidth'] as int? ?? 0,
lastReportedHeight: json['lastReportedHeight'] as int? ?? 0,
renderer: json['renderer'] as RTCVideoRenderer?,
alert: json['alert'] as String?,
);
}
Map<String, dynamic> _$ConnectionSessionStateToJson(ConnectionSessionState instance) {
final val = <String, dynamic>{};
val['connected'] = instance.connected;
val['connecting'] = instance.connecting;
val['signalingConnected'] = instance.signalingConnected;
val['myDeviceId'] = instance.myDeviceId;
val['status'] = instance.status;
val['bindings'] = instance.bindings;
val['onlineUids'] = instance.onlineUids;
val['selectedDeviceUid'] = instance.selectedDeviceUid;
val['deviceRefreshing'] = instance.deviceRefreshing;
val['deviceError'] = instance.deviceError;
val['redeeming'] = instance.redeeming;
val['redeemError'] = instance.redeemError;
val['stats'] = instance.stats;
val['videoAspect'] = instance.videoAspect;
val['streamMode'] = instance.streamMode;
val['selfCodecTextureId'] = instance.selfCodecTextureId;
val['selfCodecReady'] = instance.selfCodecReady;
val['selfCodecSupported'] = instance.selfCodecSupported;
val['recording'] = instance.recording;
val['recordStatus'] = instance.recordStatus;
val['pendingRecordStart'] = instance.pendingRecordStart;
val['selectedResolution'] = instance.selectedResolution;
val['resolutionOptions'] = instance.resolutionOptions;
val['fpsOptions'] = instance.fpsOptions;
val['currentFps'] = instance.currentFps;
val['lastReportedWidth'] = instance.lastReportedWidth;
val['lastReportedHeight'] = instance.lastReportedHeight;
val['renderer'] = instance.renderer;
val['alert'] = instance.alert;
return val;
}
/// @nodoc /// @nodoc
mixin _$ConnectionSessionState { mixin _$ConnectionSessionState {
/// 是否已建立 WebRTC 连接(可开始远程控制)。 /// 是否已建立 WebRTC 连接(可开始远程控制)。
bool get connected => throw _privateConstructorUsedError; bool get connected => throw _privateConstructorUsedError;
/// 是否正在连接信令服务器 / 建立 WebRTC。 /// 是否正在连接信令服务器 / 建立 WebRTC。
bool get connecting => throw _privateConstructorUsedError; bool get connecting => throw _privateConstructorUsedError;
/// 信令WebSocket是否已连接并注册成功。
bool get signalingConnected => throw _privateConstructorUsedError;
/// 信令注册后服务端下发的本机设备ID。
String? get myDeviceId => throw _privateConstructorUsedError;
/// 状态提示文本。 /// 状态提示文本。
String get status => throw _privateConstructorUsedError; String get status => throw _privateConstructorUsedError;
/// 主控端已绑定的被控设备列表(供用户选择连接)。
List<BindingDevice> get bindings => throw _privateConstructorUsedError;
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
Set<String> get onlineUids => throw _privateConstructorUsedError;
/// 用户在绑定设备列表中选中的目标设备 uid。
String? get selectedDeviceUid => throw _privateConstructorUsedError;
/// 是否正在拉取绑定设备列表 / 在线状态。
bool get deviceRefreshing => throw _privateConstructorUsedError;
/// 设备列表加载失败时的错误提示null 表示无错误)。
String? get deviceError => throw _privateConstructorUsedError;
/// 是否正在兑换配对码建立绑定。
bool get redeeming => throw _privateConstructorUsedError;
/// 配对码兑换失败提示null 表示无错误)。
String? get redeemError => throw _privateConstructorUsedError;
/// 连接统计文本(每秒刷新)。 /// 连接统计文本(每秒刷新)。
String get stats => throw _privateConstructorUsedError; String get stats => throw _privateConstructorUsedError;
/// 当前视频宽高比。 /// 当前视频宽高比。
double get videoAspect => throw _privateConstructorUsedError; double get videoAspect => throw _privateConstructorUsedError;
/// 当前串流模式0=WebRTC 全托管1=自编码。 /// 当前串流模式0=WebRTC 全托管1=自编码。
int get streamMode => throw _privateConstructorUsedError; int get streamMode => throw _privateConstructorUsedError;
/// 自编码解码纹理 id。 /// 自编码解码纹理 id。
int? get selfCodecTextureId => throw _privateConstructorUsedError; int? get selfCodecTextureId => throw _privateConstructorUsedError;
/// 自编码解码器是否就绪。 /// 自编码解码器是否就绪。
bool get selfCodecReady => throw _privateConstructorUsedError; bool get selfCodecReady => throw _privateConstructorUsedError;
/// 当前平台是否支持自编码硬解。 /// 当前平台是否支持自编码硬解。
bool get selfCodecSupported => throw _privateConstructorUsedError; bool get selfCodecSupported => throw _privateConstructorUsedError;
/// 是否正在录制远程视频。 /// 是否正在录制远程视频。
bool get recording => throw _privateConstructorUsedError; bool get recording => throw _privateConstructorUsedError;
/// 录制状态提示。 /// 录制状态提示。
String get recordStatus => throw _privateConstructorUsedError; String get recordStatus => throw _privateConstructorUsedError;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。 /// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
bool get pendingRecordStart => throw _privateConstructorUsedError; bool get pendingRecordStart => throw _privateConstructorUsedError;
/// 当前选中的分辨率预设下标。 /// 当前选中的分辨率预设下标。
int get selectedResolution => throw _privateConstructorUsedError; int get selectedResolution => throw _privateConstructorUsedError;
/// 分辨率预设列表。 /// 分辨率预设列表。
List<Map<String, Object>> get resolutionOptions => List<Map<String, Object>> get resolutionOptions => throw _privateConstructorUsedError;
throw _privateConstructorUsedError;
/// 帧率档位(收到被控端上报后以上报列表为准)。 /// 帧率档位(收到被控端上报后以上报列表为准)。
List<int> get fpsOptions => throw _privateConstructorUsedError; List<int> get fpsOptions => throw _privateConstructorUsedError;
/// 被控端当前采集帧率0 表示尚未收到上报)。 /// 被控端当前采集帧率0 表示尚未收到上报)。
int get currentFps => throw _privateConstructorUsedError; int get currentFps => throw _privateConstructorUsedError;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。 /// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
int get lastReportedWidth => throw _privateConstructorUsedError; int get lastReportedWidth => throw _privateConstructorUsedError;
/// 被控端最近上报的实际采集尺寸。 /// 被控端最近上报的实际采集尺寸。
int get lastReportedHeight => throw _privateConstructorUsedError; int get lastReportedHeight => throw _privateConstructorUsedError;
/// 远端视频渲染器(未连接为 null /// 远端视频渲染器(未连接为 null
RTCVideoRenderer? get renderer => throw _privateConstructorUsedError; RTCVideoRenderer? get renderer => throw _privateConstructorUsedError;
/// 一次性提示消息(消费后由控制器置空)。 /// 一次性提示消息(消费后由控制器置空)。
String? get alert => throw _privateConstructorUsedError; String? get alert => throw _privateConstructorUsedError;
@@ -95,7 +161,16 @@ abstract class $ConnectionSessionStateCopyWith<$Res> {
$Res call({ $Res call({
bool connected, bool connected,
bool connecting, bool connecting,
bool signalingConnected,
String? myDeviceId,
String status, String status,
List<BindingDevice> bindings,
Set<String> onlineUids,
String? selectedDeviceUid,
bool deviceRefreshing,
String? deviceError,
bool redeeming,
String? redeemError,
String stats, String stats,
double videoAspect, double videoAspect,
int streamMode, int streamMode,
@@ -129,14 +204,21 @@ class _$ConnectionSessionStateCopyWithImpl<
// ignore: unused_field // ignore: unused_field
final $Res Function($Val) _then; 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') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
Object? connected = null, Object? connected = null,
Object? connecting = null, Object? connecting = null,
Object? signalingConnected = null,
Object? myDeviceId = freezed,
Object? status = null, 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? stats = null,
Object? videoAspect = null, Object? videoAspect = null,
Object? streamMode = null, Object? streamMode = null,
@@ -165,10 +247,46 @@ class _$ConnectionSessionStateCopyWithImpl<
? _value.connecting ? _value.connecting
: connecting // ignore: cast_nullable_to_non_nullable : connecting // ignore: cast_nullable_to_non_nullable
as bool, 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 status: null == status
? _value.status ? _value.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as String, as String,
bindings: null == bindings
? _value.bindings
: bindings // ignore: cast_nullable_to_non_nullable
as List<BindingDevice>,
onlineUids: null == onlineUids
? _value.onlineUids
: onlineUids // ignore: cast_nullable_to_non_nullable
as Set<String>,
selectedDeviceUid: freezed == selectedDeviceUid
? _value.selectedDeviceUid
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
as String?,
deviceRefreshing: null == deviceRefreshing
? _value.deviceRefreshing
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
as bool,
deviceError: freezed == deviceError
? _value.deviceError
: deviceError // ignore: cast_nullable_to_non_nullable
as String?,
redeeming: null == redeeming
? _value.redeeming
: redeeming // ignore: cast_nullable_to_non_nullable
as bool,
redeemError: freezed == redeemError
? _value.redeemError
: redeemError // ignore: cast_nullable_to_non_nullable
as String?,
stats: null == stats stats: null == stats
? _value.stats ? _value.stats
: stats // ignore: cast_nullable_to_non_nullable : stats // ignore: cast_nullable_to_non_nullable
@@ -237,12 +355,219 @@ class _$ConnectionSessionStateCopyWithImpl<
? _value.alert ? _value.alert
: alert // ignore: cast_nullable_to_non_nullable : alert // ignore: cast_nullable_to_non_nullable
as String?, as String?,
) ) as $Val,
as $Val,
); );
} }
} }
/// @nodoc
class _$ConnectionSessionStateImpl with _$ConnectionSessionState
implements ConnectionSessionState {
const _$ConnectionSessionStateImpl({
this.connected = false,
this.connecting = false,
this.signalingConnected = false,
this.myDeviceId,
this.status = '',
this.bindings = const [],
this.onlineUids = const {},
this.selectedDeviceUid,
this.deviceRefreshing = false,
this.deviceError,
this.redeeming = false,
this.redeemError,
this.stats = '',
this.videoAspect = 16 / 9,
this.streamMode = 0,
this.selfCodecTextureId,
this.selfCodecReady = false,
this.selfCodecSupported = true,
this.recording = false,
this.recordStatus = '',
this.pendingRecordStart = false,
this.selectedResolution = 0,
this.resolutionOptions = kResolutionOptions,
this.fpsOptions = kDefaultFpsOptions,
this.currentFps = 0,
this.lastReportedWidth = 0,
this.lastReportedHeight = 0,
this.renderer,
this.alert,
});
@override
@JsonKey()
final bool connected;
@override
@JsonKey()
final bool connecting;
@override
@JsonKey()
final bool signalingConnected;
@override
@JsonKey()
final String? myDeviceId;
@override
@JsonKey()
final String status;
@override
@JsonKey()
final List<BindingDevice> bindings;
@override
@JsonKey()
final Set<String> onlineUids;
@override
@JsonKey()
final String? selectedDeviceUid;
@override
@JsonKey()
final bool deviceRefreshing;
@override
@JsonKey()
final String? deviceError;
@override
@JsonKey()
final bool redeeming;
@override
@JsonKey()
final String? redeemError;
@override
@JsonKey()
final String stats;
@override
@JsonKey()
final double videoAspect;
@override
@JsonKey()
final int streamMode;
@override
@JsonKey()
final int? selfCodecTextureId;
@override
@JsonKey()
final bool selfCodecReady;
@override
@JsonKey()
final bool selfCodecSupported;
@override
@JsonKey()
final bool recording;
@override
@JsonKey()
final String recordStatus;
@override
@JsonKey()
final bool pendingRecordStart;
@override
@JsonKey()
final int selectedResolution;
@override
@JsonKey()
final List<Map<String, Object>> resolutionOptions;
@override
@JsonKey()
final List<int> fpsOptions;
@override
@JsonKey()
final int currentFps;
@override
@JsonKey()
final int lastReportedWidth;
@override
@JsonKey()
final int lastReportedHeight;
@override
@JsonKey()
final RTCVideoRenderer? renderer;
@override
@JsonKey()
final String? alert;
@override
$ConnectionSessionStateCopyWith<ConnectionSessionState> get copyWith =>
__$$ConnectionSessionStateImplCopyWithImpl<ConnectionSessionState>(
this,
(value) => value,
);
Map<String, dynamic> toJson() => _$ConnectionSessionStateToJson(this);
@override
String toString() {
return 'ConnectionSessionState(${[
if (connected != null) 'connected: $connected',
if (connecting != null) 'connecting: $connecting',
if (signalingConnected != null) 'signalingConnected: $signalingConnected',
if (myDeviceId != null) 'myDeviceId: $myDeviceId',
if (status != null) 'status: $status',
if (bindings != null) 'bindings: $bindings',
if (onlineUids != null) 'onlineUids: $onlineUids',
if (selectedDeviceUid != null) 'selectedDeviceUid: $selectedDeviceUid',
if (deviceRefreshing != null) 'deviceRefreshing: $deviceRefreshing',
if (deviceError != null) 'deviceError: $deviceError',
if (redeeming != null) 'redeeming: $redeeming',
if (redeemError != null) 'redeemError: $redeemError',
if (stats != null) 'stats: $stats',
if (videoAspect != null) 'videoAspect: $videoAspect',
if (streamMode != null) 'streamMode: $streamMode',
if (selfCodecTextureId != null) 'selfCodecTextureId: $selfCodecTextureId',
if (selfCodecReady != null) 'selfCodecReady: $selfCodecReady',
if (selfCodecSupported != null) 'selfCodecSupported: $selfCodecSupported',
if (recording != null) 'recording: $recording',
if (recordStatus != null) 'recordStatus: $recordStatus',
if (pendingRecordStart != null) 'pendingRecordStart: $pendingRecordStart',
if (selectedResolution != null) 'selectedResolution: $selectedResolution',
if (resolutionOptions != null) 'resolutionOptions: $resolutionOptions',
if (fpsOptions != null) 'fpsOptions: $fpsOptions',
if (currentFps != null) 'currentFps: $currentFps',
if (lastReportedWidth != null) 'lastReportedWidth: $lastReportedWidth',
if (lastReportedHeight != null) 'lastReportedHeight: $lastReportedHeight',
if (renderer != null) 'renderer: $renderer',
if (alert != null) 'alert: $alert',
].where((t) => t != null).join(', ')})';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ConnectionSessionStateImpl &&
(identical(other.connected, connected) || other.connected == connected)
&& (identical(other.connecting, connecting) || other.connecting == connecting)
&& (identical(other.signalingConnected, signalingConnected) || other.signalingConnected == signalingConnected)
&& (identical(other.myDeviceId, myDeviceId) || other.myDeviceId == myDeviceId)
&& (identical(other.status, status) || other.status == status)
&& (identical(other.bindings, bindings) || other.bindings == bindings)
&& (identical(other.onlineUids, onlineUids) || other.onlineUids == onlineUids)
&& (identical(other.selectedDeviceUid, selectedDeviceUid) || other.selectedDeviceUid == selectedDeviceUid)
&& (identical(other.deviceRefreshing, deviceRefreshing) || other.deviceRefreshing == deviceRefreshing)
&& (identical(other.deviceError, deviceError) || other.deviceError == deviceError)
&& (identical(other.redeeming, redeeming) || other.redeeming == redeeming)
&& (identical(other.redeemError, redeemError) || other.redeemError == redeemError)
&& (identical(other.stats, stats) || other.stats == stats)
&& (identical(other.videoAspect, videoAspect) || other.videoAspect == videoAspect)
&& (identical(other.streamMode, streamMode) || other.streamMode == streamMode)
&& (identical(other.selfCodecTextureId, selfCodecTextureId) || other.selfCodecTextureId == selfCodecTextureId)
&& (identical(other.selfCodecReady, selfCodecReady) || other.selfCodecReady == selfCodecReady)
&& (identical(other.selfCodecSupported, selfCodecSupported) || other.selfCodecSupported == selfCodecSupported)
&& (identical(other.recording, recording) || other.recording == recording)
&& (identical(other.recordStatus, recordStatus) || other.recordStatus == recordStatus)
&& (identical(other.pendingRecordStart, pendingRecordStart) || other.pendingRecordStart == pendingRecordStart)
&& (identical(other.selectedResolution, selectedResolution) || other.selectedResolution == selectedResolution)
&& (identical(other.resolutionOptions, resolutionOptions) || other.resolutionOptions == resolutionOptions)
&& (identical(other.fpsOptions, fpsOptions) || other.fpsOptions == fpsOptions)
&& (identical(other.currentFps, currentFps) || other.currentFps == currentFps)
&& (identical(other.lastReportedWidth, lastReportedWidth) || other.lastReportedWidth == lastReportedWidth)
&& (identical(other.lastReportedHeight, lastReportedHeight) || other.lastReportedHeight == lastReportedHeight)
&& (identical(other.renderer, renderer) || other.renderer == renderer)
&& (identical(other.alert, alert) || other.alert == alert)
);
}
@override
int get hashCode => connected.hashCode ^ connecting.hashCode ^ signalingConnected.hashCode ^ myDeviceId.hashCode ^ status.hashCode ^ bindings.hashCode ^ onlineUids.hashCode ^ selectedDeviceUid.hashCode ^ deviceRefreshing.hashCode ^ deviceError.hashCode ^ redeeming.hashCode ^ redeemError.hashCode ^ stats.hashCode ^ videoAspect.hashCode ^ streamMode.hashCode ^ selfCodecTextureId.hashCode ^ selfCodecReady.hashCode ^ selfCodecSupported.hashCode ^ recording.hashCode ^ recordStatus.hashCode ^ pendingRecordStart.hashCode ^ selectedResolution.hashCode ^ resolutionOptions.hashCode ^ fpsOptions.hashCode ^ currentFps.hashCode ^ lastReportedWidth.hashCode ^ lastReportedHeight.hashCode ^ renderer.hashCode ^ alert.hashCode;
}
/// @nodoc /// @nodoc
abstract class _$$ConnectionSessionStateImplCopyWith<$Res> abstract class _$$ConnectionSessionStateImplCopyWith<$Res>
implements $ConnectionSessionStateCopyWith<$Res> { implements $ConnectionSessionStateCopyWith<$Res> {
@@ -255,7 +580,16 @@ abstract class _$$ConnectionSessionStateImplCopyWith<$Res>
$Res call({ $Res call({
bool connected, bool connected,
bool connecting, bool connecting,
bool signalingConnected,
String? myDeviceId,
String status, String status,
List<BindingDevice> bindings,
Set<String> onlineUids,
String? selectedDeviceUid,
bool deviceRefreshing,
String? deviceError,
bool redeeming,
String? redeemError,
String stats, String stats,
double videoAspect, double videoAspect,
int streamMode, int streamMode,
@@ -286,14 +620,21 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
$Res Function(_$ConnectionSessionStateImpl) _then, $Res Function(_$ConnectionSessionStateImpl) _then,
) : super(_value, _then); ) : super(_value, _then);
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
Object? connected = null, Object? connected = null,
Object? connecting = null, Object? connecting = null,
Object? signalingConnected = null,
Object? myDeviceId = freezed,
Object? status = null, 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? stats = null,
Object? videoAspect = null, Object? videoAspect = null,
Object? streamMode = null, Object? streamMode = null,
@@ -322,10 +663,46 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
? _value.connecting ? _value.connecting
: connecting // ignore: cast_nullable_to_non_nullable : connecting // ignore: cast_nullable_to_non_nullable
as bool, 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 status: null == status
? _value.status ? _value.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as String, as String,
bindings: null == bindings
? _value.bindings
: bindings // ignore: cast_nullable_to_non_nullable
as List<BindingDevice>,
onlineUids: null == onlineUids
? _value.onlineUids
: onlineUids // ignore: cast_nullable_to_non_nullable
as Set<String>,
selectedDeviceUid: freezed == selectedDeviceUid
? _value.selectedDeviceUid
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
as String?,
deviceRefreshing: null == deviceRefreshing
? _value.deviceRefreshing
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
as bool,
deviceError: freezed == deviceError
? _value.deviceError
: deviceError // ignore: cast_nullable_to_non_nullable
as String?,
redeeming: null == redeeming
? _value.redeeming
: redeeming // ignore: cast_nullable_to_non_nullable
as bool,
redeemError: freezed == redeemError
? _value.redeemError
: redeemError // ignore: cast_nullable_to_non_nullable
as String?,
stats: null == stats stats: null == stats
? _value.stats ? _value.stats
: stats // ignore: cast_nullable_to_non_nullable : stats // ignore: cast_nullable_to_non_nullable
@@ -367,11 +744,11 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
: selectedResolution // ignore: cast_nullable_to_non_nullable : selectedResolution // ignore: cast_nullable_to_non_nullable
as int, as int,
resolutionOptions: null == resolutionOptions resolutionOptions: null == resolutionOptions
? _value._resolutionOptions ? _value.resolutionOptions
: resolutionOptions // ignore: cast_nullable_to_non_nullable : resolutionOptions // ignore: cast_nullable_to_non_nullable
as List<Map<String, Object>>, as List<Map<String, Object>>,
fpsOptions: null == fpsOptions fpsOptions: null == fpsOptions
? _value._fpsOptions ? _value.fpsOptions
: fpsOptions // ignore: cast_nullable_to_non_nullable : fpsOptions // ignore: cast_nullable_to_non_nullable
as List<int>, as List<int>,
currentFps: null == currentFps currentFps: null == currentFps
@@ -399,344 +776,3 @@ class __$$ConnectionSessionStateImplCopyWithImpl<$Res>
} }
} }
/// @nodoc
class _$ConnectionSessionStateImpl implements _ConnectionSessionState {
const _$ConnectionSessionStateImpl({
this.connected = false,
this.connecting = false,
this.status = '',
this.stats = '',
this.videoAspect = 16 / 9,
this.streamMode = 0,
this.selfCodecTextureId,
this.selfCodecReady = false,
this.selfCodecSupported = true,
this.recording = false,
this.recordStatus = '',
this.pendingRecordStart = false,
this.selectedResolution = 0,
final List<Map<String, Object>> resolutionOptions = kResolutionOptions,
final List<int> fpsOptions = kDefaultFpsOptions,
this.currentFps = 0,
this.lastReportedWidth = 0,
this.lastReportedHeight = 0,
this.renderer,
this.alert,
}) : _resolutionOptions = resolutionOptions,
_fpsOptions = fpsOptions;
/// 是否已建立 WebRTC 连接(可开始远程控制)。
@override
@JsonKey()
final bool connected;
/// 是否正在连接信令服务器 / 建立 WebRTC。
@override
@JsonKey()
final bool connecting;
/// 状态提示文本。
@override
@JsonKey()
final String status;
/// 连接统计文本(每秒刷新)。
@override
@JsonKey()
final String stats;
/// 当前视频宽高比。
@override
@JsonKey()
final double videoAspect;
/// 当前串流模式0=WebRTC 全托管1=自编码。
@override
@JsonKey()
final int streamMode;
/// 自编码解码纹理 id。
@override
final int? selfCodecTextureId;
/// 自编码解码器是否就绪。
@override
@JsonKey()
final bool selfCodecReady;
/// 当前平台是否支持自编码硬解。
@override
@JsonKey()
final bool selfCodecSupported;
/// 是否正在录制远程视频。
@override
@JsonKey()
final bool recording;
/// 录制状态提示。
@override
@JsonKey()
final String recordStatus;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
@override
@JsonKey()
final bool pendingRecordStart;
/// 当前选中的分辨率预设下标。
@override
@JsonKey()
final int selectedResolution;
/// 分辨率预设列表。
final List<Map<String, Object>> _resolutionOptions;
/// 分辨率预设列表。
@override
@JsonKey()
List<Map<String, Object>> get resolutionOptions {
if (_resolutionOptions is EqualUnmodifiableListView)
return _resolutionOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_resolutionOptions);
}
/// 帧率档位(收到被控端上报后以上报列表为准)。
final List<int> _fpsOptions;
/// 帧率档位(收到被控端上报后以上报列表为准)。
@override
@JsonKey()
List<int> get fpsOptions {
if (_fpsOptions is EqualUnmodifiableListView) return _fpsOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_fpsOptions);
}
/// 被控端当前采集帧率0 表示尚未收到上报)。
@override
@JsonKey()
final int currentFps;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
@override
@JsonKey()
final int lastReportedWidth;
/// 被控端最近上报的实际采集尺寸。
@override
@JsonKey()
final int lastReportedHeight;
/// 远端视频渲染器(未连接为 null
@override
final RTCVideoRenderer? renderer;
/// 一次性提示消息(消费后由控制器置空)。
@override
final String? alert;
@override
String toString() {
return 'ConnectionSessionState(connected: $connected, connecting: $connecting, status: $status, stats: $stats, videoAspect: $videoAspect, streamMode: $streamMode, selfCodecTextureId: $selfCodecTextureId, selfCodecReady: $selfCodecReady, selfCodecSupported: $selfCodecSupported, recording: $recording, recordStatus: $recordStatus, pendingRecordStart: $pendingRecordStart, selectedResolution: $selectedResolution, resolutionOptions: $resolutionOptions, fpsOptions: $fpsOptions, currentFps: $currentFps, lastReportedWidth: $lastReportedWidth, lastReportedHeight: $lastReportedHeight, renderer: $renderer, alert: $alert)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ConnectionSessionStateImpl &&
(identical(other.connected, connected) ||
other.connected == connected) &&
(identical(other.connecting, connecting) ||
other.connecting == connecting) &&
(identical(other.status, status) || other.status == status) &&
(identical(other.stats, stats) || other.stats == stats) &&
(identical(other.videoAspect, videoAspect) ||
other.videoAspect == videoAspect) &&
(identical(other.streamMode, streamMode) ||
other.streamMode == streamMode) &&
(identical(other.selfCodecTextureId, selfCodecTextureId) ||
other.selfCodecTextureId == selfCodecTextureId) &&
(identical(other.selfCodecReady, selfCodecReady) ||
other.selfCodecReady == selfCodecReady) &&
(identical(other.selfCodecSupported, selfCodecSupported) ||
other.selfCodecSupported == selfCodecSupported) &&
(identical(other.recording, recording) ||
other.recording == recording) &&
(identical(other.recordStatus, recordStatus) ||
other.recordStatus == recordStatus) &&
(identical(other.pendingRecordStart, pendingRecordStart) ||
other.pendingRecordStart == pendingRecordStart) &&
(identical(other.selectedResolution, selectedResolution) ||
other.selectedResolution == selectedResolution) &&
const DeepCollectionEquality().equals(
other._resolutionOptions,
_resolutionOptions,
) &&
const DeepCollectionEquality().equals(
other._fpsOptions,
_fpsOptions,
) &&
(identical(other.currentFps, currentFps) ||
other.currentFps == currentFps) &&
(identical(other.lastReportedWidth, lastReportedWidth) ||
other.lastReportedWidth == lastReportedWidth) &&
(identical(other.lastReportedHeight, lastReportedHeight) ||
other.lastReportedHeight == lastReportedHeight) &&
(identical(other.renderer, renderer) ||
other.renderer == renderer) &&
(identical(other.alert, alert) || other.alert == alert));
}
@override
int get hashCode => Object.hashAll([
runtimeType,
connected,
connecting,
status,
stats,
videoAspect,
streamMode,
selfCodecTextureId,
selfCodecReady,
selfCodecSupported,
recording,
recordStatus,
pendingRecordStart,
selectedResolution,
const DeepCollectionEquality().hash(_resolutionOptions),
const DeepCollectionEquality().hash(_fpsOptions),
currentFps,
lastReportedWidth,
lastReportedHeight,
renderer,
alert,
]);
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl>
get copyWith =>
__$$ConnectionSessionStateImplCopyWithImpl<_$ConnectionSessionStateImpl>(
this,
_$identity,
);
}
abstract class _ConnectionSessionState implements ConnectionSessionState {
const factory _ConnectionSessionState({
final bool connected,
final bool connecting,
final String status,
final String stats,
final double videoAspect,
final int streamMode,
final int? selfCodecTextureId,
final bool selfCodecReady,
final bool selfCodecSupported,
final bool recording,
final String recordStatus,
final bool pendingRecordStart,
final int selectedResolution,
final List<Map<String, Object>> resolutionOptions,
final List<int> fpsOptions,
final int currentFps,
final int lastReportedWidth,
final int lastReportedHeight,
final RTCVideoRenderer? renderer,
final String? alert,
}) = _$ConnectionSessionStateImpl;
/// 是否已建立 WebRTC 连接(可开始远程控制)。
@override
bool get connected;
/// 是否正在连接信令服务器 / 建立 WebRTC。
@override
bool get connecting;
/// 状态提示文本。
@override
String get status;
/// 连接统计文本(每秒刷新)。
@override
String get stats;
/// 当前视频宽高比。
@override
double get videoAspect;
/// 当前串流模式0=WebRTC 全托管1=自编码。
@override
int get streamMode;
/// 自编码解码纹理 id。
@override
int? get selfCodecTextureId;
/// 自编码解码器是否就绪。
@override
bool get selfCodecReady;
/// 当前平台是否支持自编码硬解。
@override
bool get selfCodecSupported;
/// 是否正在录制远程视频。
@override
bool get recording;
/// 录制状态提示。
@override
String get recordStatus;
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。
@override
bool get pendingRecordStart;
/// 当前选中的分辨率预设下标。
@override
int get selectedResolution;
/// 分辨率预设列表。
@override
List<Map<String, Object>> get resolutionOptions;
/// 帧率档位(收到被控端上报后以上报列表为准)。
@override
List<int> get fpsOptions;
/// 被控端当前采集帧率0 表示尚未收到上报)。
@override
int get currentFps;
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
@override
int get lastReportedWidth;
/// 被控端最近上报的实际采集尺寸。
@override
int get lastReportedHeight;
/// 远端视频渲染器(未连接为 null
@override
RTCVideoRenderer? get renderer;
/// 一次性提示消息(消费后由控制器置空)。
@override
String? get alert;
/// Create a copy of ConnectionSessionState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -8,6 +8,7 @@ import '../../auth/data/auth_providers.dart';
import '../data/remote_controller.dart'; import '../data/remote_controller.dart';
import '../data/self_codec_decoder.dart'; import '../data/self_codec_decoder.dart';
import '../data/video_recorder.dart'; import '../data/video_recorder.dart';
import '../domain/binding_device.dart';
import '../domain/connection_session_state.dart'; import '../domain/connection_session_state.dart';
part 'connection_controller.g.dart'; part 'connection_controller.g.dart';
@@ -40,32 +41,159 @@ class ConnectionController extends _$ConnectionController {
void _clearAlert() => state = state.copyWith(alert: null); void _clearAlert() => state = state.copyWith(alert: null);
/// 发起连接:先确保已登录,携带 Bearer 建立信令,成功后建立 WebRTC /// 仅建立信令WebSocket连接**不**发起远程控制
Future<void> connect({ ///
required String serverUrl, /// 进入设置页即可调用;重复调用会被忽略。
required String targetDeviceId, Future<void> connectSignaling({required String serverUrl}) async {
required String authType, if (serverUrl.isEmpty) {
required String authValue, _alert('信令服务器地址为空');
}) async {
if (serverUrl.isEmpty || targetDeviceId.isEmpty) {
_alert('请填写服务器地址和目标设备ID');
return; return;
} }
if (_remote != null || state.signalingConnected) return;
final authRepository = ref.read(authRepositoryProvider); final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...'); state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...');
_remote = RemoteController( _remote = RemoteController(
serverUrl: serverUrl, serverUrl: serverUrl,
targetDeviceId: targetDeviceId,
authRepository: authRepository, authRepository: authRepository,
); );
_wireRemoteCallbacks(); _wireRemoteCallbacks();
await _remote!.connect(authType: authType, authValue: authValue); await _remote!.connectSignaling();
} }
/// 发起远程控制:需信令已就绪,向目标设备创建 Offer。
Future<void> startControl({
required String targetDeviceId,
required String authType,
required String authValue,
}) async {
if (targetDeviceId.isEmpty) {
_alert('请填写目标设备ID');
return;
}
if (!state.signalingConnected || _remote == null) {
_alert('信令未就绪,请稍后重试');
return;
}
state = state.copyWith(connecting: true, status: '状态: 正在发起连接...');
_remote!.startControl(
target: targetDeviceId,
authType: authType,
authValue: authValue,
);
}
/// 拉取已绑定设备列表,并合并在线设备状态。
///
/// 参考 Android 控制端的 [MainViewModel.loadBindings] / [getOnlineDevices]
/// 用在线列表标记绑定设备是否可立即连接。
Future<void> loadDevices() async {
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(deviceRefreshing: true, deviceError: null);
try {
final bindings = await authRepository.getBindings();
List<BindingDevice> online = const [];
try {
online = await authRepository.getOnlineDevices();
} on Object {
// 在线列表接口失败时,仍展示全部绑定(仅不标记在线)。
}
final onlineUids = {for (final d in online) d.deviceUid};
final merged = [
for (final b in bindings)
b.copyWith(online: onlineUids.contains(b.deviceUid)),
];
final stillSelected = merged
.where((b) => b.deviceUid == state.selectedDeviceUid)
.isNotEmpty;
state = state.copyWith(
bindings: merged,
onlineUids: onlineUids,
deviceRefreshing: false,
selectedDeviceUid: stillSelected ? state.selectedDeviceUid : null,
);
} on Object catch (e) {
debugPrint(e.toString());
state = state.copyWith(
deviceRefreshing: false,
deviceError: '加载绑定设备失败:$e',
);
}
}
/// 在绑定设备列表中选择一个目标设备(仅记录选择,不下发连接)。
void selectDevice(String uid) {
if (state.selectedDeviceUid == uid) return;
state = state.copyWith(selectedDeviceUid: uid);
}
/// 当前选中的绑定设备(无则返回 null
BindingDevice? get selectedDevice {
final uid = state.selectedDeviceUid;
if (uid == null) return null;
for (final b in state.bindings) {
if (b.deviceUid == uid) return b;
}
return null;
}
/// 向选中的绑定设备发起远程控制。
///
/// 鉴权方式取自 [BindingDevice.authType],凭据由 UI 的鉴权对话框收集后传入。
Future<void> connectSelectedDevice({
required String authType,
required String authValue,
}) async {
final target = selectedDevice;
if (target == null) {
_alert('请先选择一个绑定的设备');
return;
}
if (!target.online) {
_alert('设备「${target.name}」当前不在线,无法连接');
return;
}
await startControl(
targetDeviceId: target.deviceUid,
authType: authType,
authValue: authValue,
);
}
/// 使用被控端展示的配对码建立绑定关系,并刷新绑定设备列表。
Future<bool> redeemPairingCode(String code) async {
final trimmed = code.trim();
if (trimmed.isEmpty) {
state = state.copyWith(redeemError: '请输入配对码');
return false;
}
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(redeeming: true, redeemError: null);
try {
await authRepository.redeemPairingCode(trimmed);
state = state.copyWith(redeeming: false);
await loadDevices();
return true;
} on Object catch (e) {
state = state.copyWith(redeeming: false, redeemError: '绑定失败:$e');
return false;
}
}
/// 清除配对码兑换错误提示。
void clearRedeemError() => state.copyWith(redeemError: null);
void _wireRemoteCallbacks() { void _wireRemoteCallbacks() {
final remote = _remote!; final remote = _remote!;
remote.onStatusChanged = _setStatus; remote.onStatusChanged = _setStatus;
remote.onRegistered = (myDeviceId) {
state = state.copyWith(
signalingConnected: true,
connecting: false,
myDeviceId: myDeviceId,
);
};
remote.onConnectionEstablished = () { remote.onConnectionEstablished = () {
state = state.copyWith( state = state.copyWith(
connected: true, connected: true,
@@ -78,20 +206,26 @@ class ConnectionController extends _$ConnectionController {
_alert('连接失败:$error'); _alert('连接失败:$error');
}; };
remote.onDisconnected = () { remote.onDisconnected = () {
state = state.copyWith(connected: false, connecting: false); state = state.copyWith(
connected: false,
connecting: false,
signalingConnected: false,
myDeviceId: null,
);
_setStatus('状态: 远端已断开'); _setStatus('状态: 远端已断开');
}; };
// 以下三种情况仅结束远程控制,保留信令连接以便重新发起。
remote.onIceDisconnected = (message) { remote.onIceDisconnected = (message) {
_alert(message); _alert(message);
disconnect(); stopControl();
}; };
remote.onTargetOffline = (message) { remote.onTargetOffline = (message) {
_alert(message); _alert(message);
state = state.copyWith(connected: false, connecting: false); stopControl();
}; };
remote.onConnectionRejected = (message) { remote.onConnectionRejected = (message) {
_alert(message); _alert(message);
state = state.copyWith(connected: false, connecting: false); stopControl();
}; };
remote.onRemoteStream = (renderer) { remote.onRemoteStream = (renderer) {
_renderer = renderer; _renderer = renderer;
@@ -114,7 +248,8 @@ class ConnectionController extends _$ConnectionController {
}; };
remote.onStreamModeReport = (mode) { remote.onStreamModeReport = (mode) {
state = state.copyWith(streamMode: mode); state = state.copyWith(streamMode: mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) { if (mode == SelfCodecDecoder.streamModeWebRtc &&
state.pendingRecordStart) {
state = state.copyWith(pendingRecordStart: false); state = state.copyWith(pendingRecordStart: false);
_startRecording(); _startRecording();
} }
@@ -261,7 +396,23 @@ class ConnectionController extends _$ConnectionController {
_remote?.sendControlCommand(command); _remote?.sendControlCommand(command);
} }
/// 断开连接并复位全部 UI 状态 /// 结束远程控制但保持信令连接,便于重新选择设备发起连接
Future<void> stopControl() async {
await _videoRecorder.dispose();
await _remote?.stopControl();
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
final signalingAlive = state.signalingConnected;
state = ConnectionSessionState(
status: signalingAlive ? '状态: 已连接信令服务器' : '状态: 已停止',
signalingConnected: signalingAlive,
myDeviceId: state.myDeviceId,
alert: state.alert,
);
}
/// 断开全部连接(含信令)并复位 UI 状态。
Future<void> disconnect() async { Future<void> disconnect() async {
await _videoRecorder.dispose(); await _videoRecorder.dispose();
await _remote?.disconnect(); await _remote?.disconnect();
@@ -269,7 +420,7 @@ class ConnectionController extends _$ConnectionController {
_renderer?.removeListener(_onRendererUpdate); _renderer?.removeListener(_onRendererUpdate);
_renderer = null; _renderer = null;
_lastResizedAspect = 0; _lastResizedAspect = 0;
state = const ConnectionSessionState(status: '状态: 已停止'); state = ConnectionSessionState(status: '状态: 已停止', alert: state.alert);
} }
/// 消费一次性提示消息UI 展示后调用)。 /// 消费一次性提示消息UI 展示后调用)。

View File

@@ -7,7 +7,7 @@ part of 'connection_controller.dart';
// ************************************************************************** // **************************************************************************
String _$connectionControllerHash() => String _$connectionControllerHash() =>
r'bea162831e84bb80f7921d7c774d76e07969b023'; r'83ae35ab7dbb3a7d23106106040e9146f1dc6782';
/// 连接控制会话控制器:编排信令 + WebRTC管理控制端全部 UI 状态。 /// 连接控制会话控制器:编排信令 + WebRTC管理控制端全部 UI 状态。
/// ///

View File

@@ -266,7 +266,8 @@ class _ControlPageState extends ConsumerState<ControlPage> {
CupertinoButton( CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () async { onPressed: () async {
await ref.read(connectionControllerProvider.notifier).disconnect(); // 仅结束远程控制,保留信令连接以便返回设置页后直接重连。
await ref.read(connectionControllerProvider.notifier).stopControl();
if (mounted) context.go('/'); if (mounted) context.go('/');
}, },
child: const Icon( child: const Icon(

View File

@@ -6,11 +6,12 @@ import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../app/constants/app_constants.dart'; import '../../../../app/constants/app_constants.dart';
import '../../../auth/presentation/auth_controller.dart'; import '../../../auth/presentation/auth_controller.dart';
import '../../../auth/presentation/widgets/login_dialog.dart'; import '../../../auth/presentation/widgets/login_dialog.dart';
import '../../domain/binding_device.dart';
import '../../domain/connection_session_state.dart'; import '../../domain/connection_session_state.dart';
import '../connection_controller.dart'; import '../connection_controller.dart';
import '../widgets/auth_dialog.dart'; import '../widgets/auth_dialog.dart';
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。 /// 连接设置页:服务器地址 / 绑定设备选择 / 登录 / 连接。
class SetupPage extends ConsumerStatefulWidget { class SetupPage extends ConsumerStatefulWidget {
const SetupPage({super.key}); const SetupPage({super.key});
@@ -19,32 +20,73 @@ class SetupPage extends ConsumerStatefulWidget {
} }
class _SetupPageState extends ConsumerState<SetupPage> { class _SetupPageState extends ConsumerState<SetupPage> {
final _serverUrlController = TextEditingController(
text: kDefaultSignalServer,
);
final _deviceIdController = TextEditingController(); final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
@override
void initState() {
super.initState();
// build() 期间不能改动 provider 状态,推迟到首帧之后。
WidgetsBinding.instance.addPostFrameCallback((_) => _autoConnectSignaling());
}
@override @override
void dispose() { void dispose() {
_serverUrlController.dispose();
_deviceIdController.dispose(); _deviceIdController.dispose();
_targetController.dispose();
super.dispose(); super.dispose();
} }
/// 进入页面后自动建立信令WebSocket连接并拉取绑定设备列表。
///
/// 仅连接信令并完成注册,**不会**向任何设备发起远程控制;
/// 能进入本页即已登录,携带 Bearer token 即可建连。
Future<void> _autoConnectSignaling() async {
if (!mounted) return;
final loggedIn =
ref.read(authControllerProvider).valueOrNull?.loggedIn ?? false;
if (!loggedIn) return;
await ref
.read(connectionControllerProvider.notifier)
.connectSignaling(serverUrl: kDefaultSignalServer);
if (!mounted) return;
await ref.read(connectionControllerProvider.notifier).loadDevices();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final authState = ref.watch(authControllerProvider).valueOrNull; final authState = ref.watch(authControllerProvider).valueOrNull;
final connection = ref.watch(connectionControllerProvider); final connection = ref.watch(connectionControllerProvider);
// 连接建立后跳转控制页 // 登录完成后补发信令连接;退出登录则断开信令
ref.listen(authControllerProvider, (prev, next) {
final was = prev?.valueOrNull?.loggedIn ?? false;
final now = next.valueOrNull?.loggedIn ?? false;
if (!was && now) {
_autoConnectSignaling();
} else if (was && !now) {
ref.read(connectionControllerProvider.notifier).disconnect();
}
});
// 信令注册成功后回显服务端下发的本机设备ID。
final myDeviceId = connection.myDeviceId ?? '';
if (_deviceIdController.text != myDeviceId) {
_deviceIdController.text = myDeviceId;
}
// 连接建立后跳转控制页;连接失败等一次性提示展示后立即消费。
ref.listen<ConnectionSessionState>( ref.listen<ConnectionSessionState>(
connectionControllerProvider, connectionControllerProvider,
(prev, next) { (prev, next) {
if ((prev?.connected ?? false) == false && next.connected) { if ((prev?.connected ?? false) == false && next.connected) {
context.go('/control'); context.go('/control');
return;
}
final alert = next.alert;
if (alert != null && alert.isNotEmpty) {
ref.read(connectionControllerProvider.notifier).consumeAlert();
_showAlert(alert);
} }
}, },
); );
@@ -59,25 +101,6 @@ class _SetupPageState extends ConsumerState<SetupPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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)), Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8), const SizedBox(height: 8),
CupertinoTextField( CupertinoTextField(
@@ -89,18 +112,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
horizontal: 12, horizontal: 12,
), ),
), ),
const SizedBox(height: 16),
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: l10n.targetDeviceIdPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildDeviceSection(context, l10n, connection),
const SizedBox(height: 16),
Text( Text(
connection.status, connection.status,
style: const TextStyle( style: const TextStyle(
@@ -108,6 +122,13 @@ class _SetupPageState extends ConsumerState<SetupPage> {
fontWeight: FontWeight.bold, 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), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -129,7 +150,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
} }
}, },
child: Text( child: Text(
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount, (authState?.loggedIn ?? false)
? l10n.logout
: l10n.loginAccount,
), ),
), ),
), ),
@@ -150,12 +173,142 @@ class _SetupPageState extends ConsumerState<SetupPage> {
); );
} }
/// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。
Widget _buildDeviceSection(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.boundDevices, style: const TextStyle(fontSize: 16)),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.deviceRefreshing
? null
: () => ref
.read(connectionControllerProvider.notifier)
.loadDevices(),
child: connection.deviceRefreshing
? const CupertinoActivityIndicator(radius: 9)
: Text(l10n.refreshDevices),
),
],
),
const SizedBox(height: 8),
if (connection.bindings.isEmpty)
_buildEmptyHint(context, l10n, connection)
else
_DeviceList(
devices: connection.bindings,
selectedUid: connection.selectedDeviceUid,
onSelect: (uid) => ref
.read(connectionControllerProvider.notifier)
.selectDevice(uid),
),
const SizedBox(height: 12),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.redeeming
? null
: () => _showRedeemDialog(context, l10n),
child: connection.redeeming
? const CupertinoActivityIndicator()
: Text(l10n.redeemPairingCode),
),
],
);
}
/// 无绑定设备时的引导提示(含配对失败提示)。
Widget _buildEmptyHint(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.noBoundDevices,
style: const TextStyle(fontSize: 14, color: CupertinoColors.systemGrey),
),
if (connection.redeemError != null) ...[
const SizedBox(height: 8),
Text(
connection.redeemError!,
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
),
],
],
);
}
/// 兑换配对码对话框。
Future<void> _showRedeemDialog(
BuildContext context,
AppLocalizations l10n,
) async {
final controller = TextEditingController();
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text(l10n.redeemPairingCode),
content: Column(
children: [
const SizedBox(height: 12),
CupertinoTextField(
controller: controller,
placeholder: l10n.pairingCodeHint,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(ctx).pop(false),
),
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(ctx).pop(true),
),
],
),
);
if (ok != true || !mounted) {
controller.dispose();
return;
}
final code = controller.text.trim();
// 延迟到下一帧再释放,避免对话框卸载时焦点回调访问已释放的 controller。
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (code.isEmpty) return;
final success = await ref
.read(connectionControllerProvider.notifier)
.redeemPairingCode(code);
if (success && mounted) {
_showAlert(l10n.bindSuccess);
} else if (mounted) {
final err = ref.read(connectionControllerProvider).redeemError;
if (err != null && err.isNotEmpty) _showAlert(err);
}
}
Future<void> _onConnectPressed() async { Future<void> _onConnectPressed() async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final serverUrl = _serverUrlController.text.trim(); final selected =
final target = _targetController.text.trim(); ref.read(connectionControllerProvider.notifier).selectedDevice;
if (serverUrl.isEmpty || target.isEmpty) { if (selected == null) {
_showAlert(l10n.serverAndTargetRequired); _showAlert(l10n.pleaseSelectDevice);
return;
}
if (!selected.online) {
_showAlert(l10n.deviceOfflineCannotConnect(selected.name ?? selected.deviceUid));
return; return;
} }
// 连接前确保已登录Bearer token // 连接前确保已登录Bearer token
@@ -169,12 +322,16 @@ class _SetupPageState extends ConsumerState<SetupPage> {
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
} }
// 信令若尚未就绪(如刚登录或曾断开),先补建连接。
if (!ref.read(connectionControllerProvider).signalingConnected) {
await _autoConnectSignaling();
if (!mounted) return;
}
final selection = await showAuthDialog(context); final selection = await showAuthDialog(context);
if (selection == null || !mounted) return; if (selection == null || !mounted) return;
await ref.read(connectionControllerProvider.notifier).connect( await ref.read(connectionControllerProvider.notifier).connectSelectedDevice(
serverUrl: serverUrl,
targetDeviceId: target,
authType: selection.type, authType: selection.type,
authValue: selection.value, authValue: selection.value,
); );
@@ -195,3 +352,38 @@ class _SetupPageState extends ConsumerState<SetupPage> {
); );
} }
} }
/// 绑定设备选择列表Cupertino 风格)。
class _DeviceList extends StatelessWidget {
const _DeviceList({
required this.devices,
required this.selectedUid,
required this.onSelect,
});
final List<BindingDevice> devices;
final String? selectedUid;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CupertinoListSection.insetGrouped(
margin: EdgeInsets.zero,
children: [
for (final d in devices)
CupertinoListTile(
title: Text(d.name ?? d.deviceUid),
subtitle: Text(d.online ? l10n.online : l10n.offline),
trailing: selectedUid == d.deviceUid
? const Icon(
CupertinoIcons.check_mark_circled_solid,
color: CupertinoColors.activeBlue,
)
: null,
onTap: () => onSelect(d.deviceUid),
),
],
);
}
}

View File

@@ -13,8 +13,7 @@
"connecting": "Connecting...", "connecting": "Connecting...",
"loginAccount": "Login Account", "loginAccount": "Login Account",
"connectDevice": "Connect Device", "connectDevice": "Connect Device",
"serverUrl": "Signal Server URL:",
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
"deviceId": "Device ID (assigned by server):", "deviceId": "Device ID (assigned by server):",
"deviceIdPlaceholder": "Assigned by server", "deviceIdPlaceholder": "Assigned by server",
"targetDeviceId": "Target Device ID:", "targetDeviceId": "Target Device ID:",
@@ -23,7 +22,7 @@
"password": "Password", "password": "Password",
"loginFailed": "Login failed: {error}", "loginFailed": "Login failed: {error}",
"usernamePasswordRequired": "Please enter username and password", "usernamePasswordRequired": "Please enter username and password",
"serverAndTargetRequired": "Please enter server URL and target device ID", "targetRequired": "Please enter target device ID",
"authTitle": "Connection Auth", "authTitle": "Connection Auth",
"authNone": "Passwordless", "authNone": "Passwordless",
"authNoneDesc": "Manual confirmation on the controlled device", "authNoneDesc": "Manual confirmation on the controlled device",
@@ -67,5 +66,16 @@
"forceLogout": "Account logged in elsewhere, force logged out.", "forceLogout": "Account logged in elsewhere, force logged out.",
"loginFirst": "Please login before connecting", "loginFirst": "Please login before connecting",
"pleaseFillAuth": "Please enter the code or password", "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"
} }

View File

@@ -176,18 +176,6 @@ abstract class AppLocalizations {
/// **'连接被控设备'** /// **'连接被控设备'**
String get connectDevice; 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. /// No description provided for @deviceId.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
@@ -236,11 +224,11 @@ abstract class AppLocalizations {
/// **'请输入用户名和密码'** /// **'请输入用户名和密码'**
String get usernamePasswordRequired; String get usernamePasswordRequired;
/// No description provided for @serverAndTargetRequired. /// No description provided for @targetRequired.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
/// **'请填写服务器地址和目标设备ID'** /// **'请填写目标设备ID'**
String get serverAndTargetRequired; String get targetRequired;
/// No description provided for @authTitle. /// No description provided for @authTitle.
/// ///
@@ -505,6 +493,72 @@ abstract class AppLocalizations {
/// In zh, this message translates to: /// In zh, this message translates to:
/// **'当前平台不支持自编码硬解。'** /// **'当前平台不支持自编码硬解。'**
String get streamModeSelfCodecDesc; 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 class _AppLocalizationsDelegate

View File

@@ -49,12 +49,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get connectDevice => 'Connect Device'; String get connectDevice => 'Connect Device';
@override
String get serverUrl => 'Signal Server URL:';
@override
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
@override @override
String get deviceId => 'Device ID (assigned by server):'; String get deviceId => 'Device ID (assigned by server):';
@@ -82,8 +76,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get usernamePasswordRequired => 'Please enter username and password'; String get usernamePasswordRequired => 'Please enter username and password';
@override @override
String get serverAndTargetRequired => String get targetRequired => 'Please enter target device ID';
'Please enter server URL and target device ID';
@override @override
String get authTitle => 'Connection Auth'; String get authTitle => 'Connection Auth';
@@ -239,4 +232,42 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get streamModeSelfCodecDesc => String get streamModeSelfCodecDesc =>
'Self-codec hardware decoding is not supported on this platform.'; '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';
} }

View File

@@ -49,12 +49,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get connectDevice => '连接被控设备'; String get connectDevice => '连接被控设备';
@override
String get serverUrl => '信令服务器地址:';
@override
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
@override @override
String get deviceId => '本机设备ID连接后由服务端下发无需填写:'; String get deviceId => '本机设备ID连接后由服务端下发无需填写:';
@@ -82,7 +76,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get usernamePasswordRequired => '请输入用户名和密码'; String get usernamePasswordRequired => '请输入用户名和密码';
@override @override
String get serverAndTargetRequired => '请填写服务器地址和目标设备ID'; String get targetRequired => '请填写目标设备ID';
@override @override
String get authTitle => '连接鉴权'; String get authTitle => '连接鉴权';
@@ -231,4 +225,41 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get streamModeSelfCodecDesc => '当前平台不支持自编码硬解。'; 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 => '绑定成功';
} }

View File

@@ -13,8 +13,7 @@
"connecting": "正在连接...", "connecting": "正在连接...",
"loginAccount": "登录账号", "loginAccount": "登录账号",
"connectDevice": "连接被控设备", "connectDevice": "连接被控设备",
"serverUrl": "信令服务器地址:",
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
"deviceId": "本机设备ID连接后由服务端下发无需填写:", "deviceId": "本机设备ID连接后由服务端下发无需填写:",
"deviceIdPlaceholder": "连接后由服务端下发", "deviceIdPlaceholder": "连接后由服务端下发",
"targetDeviceId": "目标被控设备ID:", "targetDeviceId": "目标被控设备ID:",
@@ -23,7 +22,7 @@
"password": "密码", "password": "密码",
"loginFailed": "登录失败:{error}", "loginFailed": "登录失败:{error}",
"usernamePasswordRequired": "请输入用户名和密码", "usernamePasswordRequired": "请输入用户名和密码",
"serverAndTargetRequired": "请填写服务器地址和目标设备ID", "targetRequired": "请填写目标设备ID",
"authTitle": "连接鉴权", "authTitle": "连接鉴权",
"authNone": "免密连接", "authNone": "免密连接",
"authNoneDesc": "被控端弹出手动确认框,无需验证码或密码", "authNoneDesc": "被控端弹出手动确认框,无需验证码或密码",
@@ -67,5 +66,16 @@
"forceLogout": "账号已在其他位置登录,已强制下线。", "forceLogout": "账号已在其他位置登录,已强制下线。",
"loginFirst": "请先登录后再连接", "loginFirst": "请先登录后再连接",
"pleaseFillAuth": "请输入验证码或密码", "pleaseFillAuth": "请输入验证码或密码",
"streamModeSelfCodecDesc": "当前平台不支持自编码硬解。" "streamModeSelfCodecDesc": "当前平台不支持自编码硬解。",
"boundDevices": "已绑定设备",
"online": "在线",
"offline": "离线",
"noBoundDevices": "暂无已绑定设备",
"refreshDevices": "刷新",
"redeemPairingCode": "使用配对码绑定",
"pairingCodeHint": "请输入被控端显示的配对码",
"bindingFailed": "绑定失败:{error}",
"deviceOfflineCannotConnect": "设备「{name}」当前不在线,无法连接",
"pleaseSelectDevice": "请先选择一个绑定的设备",
"bindSuccess": "绑定成功"
} }

View File

@@ -4,6 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart'; import 'app/app.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized();
// 全局关闭键盘焦点高亮边框
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
runApp( runApp(
const ProviderScope( const ProviderScope(
child: WebrtcControllerApp(), child: WebrtcControllerApp(),