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(
autofocus: true, data: Theme.of(context).copyWith(
onKeyEvent: (node, event) { 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) { 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(
path, await _send(
data: jsonEncode(body), () => _dio.post<dynamic>(
path,
data: jsonEncode(body),
options: Options(
headers: {
if (auth && _accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
); );
return _handle(res);
} }
Future<Map<String, dynamic>> _get(String path) async { Future<Map<String, dynamic>> _get(String path) async {
final res = await _dio.get<dynamic>( return _handle(
path, await _send(
options: Options( () => _dio.get<dynamic>(
headers: { path,
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', options: Options(
}, headers: {
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

@@ -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(),