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

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

View File

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

View File

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

View File

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