- Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题 - Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发 - 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换 - AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法 - DioAuthRepository实现401自动刷新token重试机制 - ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段
429 lines
14 KiB
Dart
429 lines
14 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||
|
||
import '../../../core/proto/control_message.pb.dart';
|
||
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';
|
||
|
||
/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。
|
||
@Riverpod(keepAlive: true)
|
||
class ConnectionController extends _$ConnectionController {
|
||
/// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。
|
||
static const MethodChannel _windowChannel = MethodChannel('app/window');
|
||
|
||
RemoteController? _remote;
|
||
RTCVideoRenderer? _renderer;
|
||
final VideoRecorder _videoRecorder = VideoRecorder();
|
||
|
||
/// 记录上一次已应用的视频宽高比,避免重复调整窗口。
|
||
double _lastResizedAspect = 0;
|
||
|
||
@override
|
||
ConnectionSessionState build() {
|
||
ref.onDispose(() {
|
||
_videoRecorder.dispose();
|
||
_remote?.disconnect();
|
||
});
|
||
return const ConnectionSessionState(status: '状态: 已停止');
|
||
}
|
||
|
||
void _setStatus(String status) => state = state.copyWith(status: status);
|
||
|
||
void _alert(String message) => state = state.copyWith(alert: message);
|
||
|
||
void _clearAlert() => state = state.copyWith(alert: null);
|
||
|
||
/// 仅建立信令(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,
|
||
authRepository: authRepository,
|
||
);
|
||
_wireRemoteCallbacks();
|
||
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,
|
||
connecting: false,
|
||
status: '状态: 已连接 - 远程控制中',
|
||
);
|
||
};
|
||
remote.onConnectionFailed = (error) {
|
||
state = state.copyWith(connecting: false);
|
||
_alert('连接失败:$error');
|
||
};
|
||
remote.onDisconnected = () {
|
||
state = state.copyWith(
|
||
connected: false,
|
||
connecting: false,
|
||
signalingConnected: false,
|
||
myDeviceId: null,
|
||
);
|
||
_setStatus('状态: 远端已断开');
|
||
};
|
||
// 以下三种情况仅结束远程控制,保留信令连接以便重新发起。
|
||
remote.onIceDisconnected = (message) {
|
||
_alert(message);
|
||
stopControl();
|
||
};
|
||
remote.onTargetOffline = (message) {
|
||
_alert(message);
|
||
stopControl();
|
||
};
|
||
remote.onConnectionRejected = (message) {
|
||
_alert(message);
|
||
stopControl();
|
||
};
|
||
remote.onRemoteStream = (renderer) {
|
||
_renderer = renderer;
|
||
state = state.copyWith(renderer: renderer);
|
||
renderer.addListener(_onRendererUpdate);
|
||
if (state.pendingRecordStart) {
|
||
state = state.copyWith(pendingRecordStart: false);
|
||
_startRecording();
|
||
}
|
||
};
|
||
remote.onStats = (stats) => state = state.copyWith(stats: stats);
|
||
remote.onSelfCodecReady = (textureId) {
|
||
state = state.copyWith(
|
||
selfCodecTextureId: textureId,
|
||
selfCodecReady: true,
|
||
);
|
||
};
|
||
remote.onSelfCodecLost = () {
|
||
state = state.copyWith(selfCodecReady: false, selfCodecTextureId: null);
|
||
};
|
||
remote.onStreamModeReport = (mode) {
|
||
state = state.copyWith(streamMode: mode);
|
||
if (mode == SelfCodecDecoder.streamModeWebRtc &&
|
||
state.pendingRecordStart) {
|
||
state = state.copyWith(pendingRecordStart: false);
|
||
_startRecording();
|
||
}
|
||
};
|
||
remote.onResolutionReported = (w, h) {
|
||
if (w > 0 && h > 0) {
|
||
final aspect = w / h;
|
||
state = state.copyWith(videoAspect: aspect);
|
||
_resizeWindowToAspect(aspect);
|
||
}
|
||
};
|
||
remote.onFpsReport = (w, h, fps, supportedFps) {
|
||
state = state.copyWith(
|
||
lastReportedWidth: w > 0 ? w : state.lastReportedWidth,
|
||
lastReportedHeight: h > 0 ? h : state.lastReportedHeight,
|
||
currentFps: fps > 0 ? fps : state.currentFps,
|
||
fpsOptions: supportedFps.isNotEmpty ? supportedFps : state.fpsOptions,
|
||
);
|
||
};
|
||
remote.onSelfCodecNotSupported = () {
|
||
state = state.copyWith(selfCodecSupported: false);
|
||
_alert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
|
||
};
|
||
remote.onTokenExpired = () {
|
||
_alert('登录已失效,请重新登录后再连接。');
|
||
_resetLogin();
|
||
};
|
||
remote.onForceLogout = () {
|
||
_alert('账号已在其他位置登录,已强制下线。');
|
||
_resetLogin();
|
||
};
|
||
}
|
||
|
||
Future<void> _resetLogin() async {
|
||
await ref.read(authRepositoryProvider).clear();
|
||
await disconnect();
|
||
state = state.copyWith(connected: false, connecting: false);
|
||
}
|
||
|
||
void _onRendererUpdate() {
|
||
final w = _renderer?.value.width ?? 0;
|
||
final h = _renderer?.value.height ?? 0;
|
||
if (w > 0 && h > 0) {
|
||
final aspect = w / h;
|
||
state = state.copyWith(videoAspect: aspect);
|
||
_resizeWindowToAspect(aspect);
|
||
}
|
||
}
|
||
|
||
/// Windows 端:保持窗口宽度不变,按视频宽高比调整窗口高度。
|
||
void _resizeWindowToAspect(double aspect) {
|
||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.windows) return;
|
||
if (aspect <= 0) return;
|
||
if ((aspect - _lastResizedAspect).abs() < 0.001) return;
|
||
_lastResizedAspect = aspect;
|
||
_windowChannel.invokeMethod('resizeToVideoAspect', aspect);
|
||
}
|
||
|
||
/// 切换分辨率:写入状态并下发指令。
|
||
void selectResolution(int index) {
|
||
state = state.copyWith(selectedResolution: index);
|
||
final o = state.resolutionOptions[index];
|
||
_remote?.sendResolutionChange(
|
||
o['width'] as int,
|
||
o['height'] as int,
|
||
o['fps'] as int,
|
||
);
|
||
}
|
||
|
||
/// 切换帧率:仅切帧率,分辨率保持不变。
|
||
void selectFps(int fps) {
|
||
if (fps <= 0 || fps == state.currentFps) return;
|
||
_remote?.sendResolutionChange(
|
||
state.lastReportedWidth,
|
||
state.lastReportedHeight,
|
||
fps,
|
||
);
|
||
}
|
||
|
||
/// 切换串流模式:WebRTC 全托管 <-> 自编码。
|
||
void toggleStreamMode() {
|
||
if (!state.selfCodecSupported) {
|
||
_alert('当前平台不支持自编码硬解。');
|
||
return;
|
||
}
|
||
final next = state.streamMode == SelfCodecDecoder.streamModeSelfCodec
|
||
? SelfCodecDecoder.streamModeWebRtc
|
||
: SelfCodecDecoder.streamModeSelfCodec;
|
||
state = state.copyWith(streamMode: next);
|
||
_remote?.sendStreamMode(next);
|
||
}
|
||
|
||
/// 切换远程视频录制:开始 / 停止。
|
||
Future<void> toggleRecord() async {
|
||
if (state.recording) {
|
||
await _stopRecording();
|
||
return;
|
||
}
|
||
final renderer = _renderer;
|
||
if (renderer == null) {
|
||
_alert('尚未连接或没有视频画面,无法录制');
|
||
return;
|
||
}
|
||
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec) {
|
||
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
|
||
state = state.copyWith(
|
||
pendingRecordStart: true,
|
||
recordStatus: '正在切回标准模式以开始录制...',
|
||
);
|
||
_remote?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
|
||
return;
|
||
}
|
||
await _startRecording();
|
||
}
|
||
|
||
Future<void> _startRecording() async {
|
||
if (state.recording) return;
|
||
final stream = _renderer?.srcObject;
|
||
if (stream == null) {
|
||
_alert('尚未接收到视频画面,无法录制');
|
||
return;
|
||
}
|
||
try {
|
||
final ok = await _videoRecorder.start(stream);
|
||
if (ok) {
|
||
state = state.copyWith(recording: true, recordStatus: '录制中...');
|
||
}
|
||
} catch (e) {
|
||
state = state.copyWith(recordStatus: '');
|
||
_alert('开始录制失败:$e');
|
||
}
|
||
}
|
||
|
||
Future<void> _stopRecording() async {
|
||
final path = await _videoRecorder.stop();
|
||
state = state.copyWith(
|
||
recording: false,
|
||
recordStatus: path != null ? '已保存:$path' : '录制已停止',
|
||
);
|
||
}
|
||
|
||
/// 发送控制指令(protobuf 二进制)。
|
||
void sendControlCommand(ControlMessage command) {
|
||
_remote?.sendControlCommand(command);
|
||
}
|
||
|
||
/// 结束远程控制但保持信令连接,便于重新选择设备发起连接。
|
||
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();
|
||
_remote = null;
|
||
_renderer?.removeListener(_onRendererUpdate);
|
||
_renderer = null;
|
||
_lastResizedAspect = 0;
|
||
state = ConnectionSessionState(status: '状态: 已停止', alert: state.alert);
|
||
}
|
||
|
||
/// 消费一次性提示消息(UI 展示后调用)。
|
||
void consumeAlert() => _clearAlert();
|
||
}
|