feat(connection): 添加远程控制路由并更新版本策略

- 新增 /connection 路由,从全局选中设备 SN 读取并渲染远程控制页
- 首页镜像操作改为静默下发 mirror 命令并跳转远程控制页
- 更新 AGENTS.md 版本管理策略为使用最新稳定版本
- 调整 pubspec.lock 中 hooks 与 record_use 版本
This commit is contained in:
TongTongStudio
2026-08-26 06:16:44 +08:00
parent 26190c1005
commit 41a514e9cc
11 changed files with 1087 additions and 12 deletions

View File

@@ -6,8 +6,8 @@
## 1. 技术栈
- **语言:** Dart(SDK `^3.12.2`,严格空安全)
- **框架:** Flutter(**锁定版本,禁止使用 `flutter upgrade` 自由升级**,详见 [§11 版本管理](#11-版本管理))
- **语言:** Dart(严格空安全,使用最新稳定版本)
- **框架:** Flutter(使用最新稳定版本)
- **状态管理:** `flutter_riverpod`(v2.x,注解 + 代码生成)
- **路由:** `go_router`(v14.x,含鉴权重定向,详见 [§9 路由鉴权](#9-路由鉴权))
- **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化)
@@ -16,8 +16,6 @@
- **依赖注入:** Riverpod Providers(不使用 GetIt)
- **国际化:** `flutter_localizations`(l10n / ARB 格式)
> ⚠️ **版本一致性约束**:不同开发者执行 `flutter upgrade` 的时间不同会导致 SDK 版本漂移,引发构建差异。Flutter 与 Dart SDK 版本必须以 CI / `.fvmrc` 为准,禁止在本地随意升级。
---
## 2. 目录结构
@@ -225,9 +223,9 @@ assets/
## 11. 版本管理
- **锁定 Flutter / Dart 版本**:在 `README` 与 CI 中明确记录 `flutter --version` 对应的版本号。
- **推荐使用 FVM**:项目根放置 `.fvmrc`,团队成员统一 `fvm use`,禁止本地自由 `flutter upgrade`。
- `pubspec.yaml` 的 `environment.sdk` 已锁定为 `^3.12.2`,依赖版本尽量用 caret 范围并定期 `flutter pub outdated` 审查。
- **使用最新稳定版本**:保持 Flutter 与 Dart SDK 为最新稳定版,可随时执行 `flutter upgrade` 获取更新。
- **依赖版本**:`pubspec.yaml` 的依赖版本尽量使用 caret 范围,并定期通过 `flutter pub outdated` 审查与升级到最新兼容版本。
- **环境约束**:保持 `environment.sdk` 为合理的 Dart SDK 下限,确保与最新稳定版兼容。
---

View File

@@ -30,6 +30,7 @@ import '../../features/device/domain/device_models.dart';
import '../../features/device/presentation/device_bind_page.dart';
import '../../features/device/presentation/device_manage_page.dart';
import '../../features/device/presentation/device_scan_page.dart';
import '../../features/connection/presentation/remote_control_page.dart';
/// 应用路由定义(GoRouter)。
///
@@ -140,6 +141,10 @@ GoRouter buildAppRouter() {
initialIndex: (state.extra is int) ? state.extra as int : 0,
),
),
GoRoute(
path: '/connection',
builder: (context, state) => _RemoteControlRoute(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, shell) => HomePage(child: shell),
branches: [
@@ -203,3 +208,12 @@ class _ScreenshotsRoute extends ConsumerWidget {
return ScreenshotPage(screenshots: list, sn: sn ?? '', initialIndex: index);
}
}
/// /connection 路由包装:从全局选中的设备 SN 读取后渲染远程控制页。
class _RemoteControlRoute extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sn = ref.watch(selectedDeviceSnProvider) ?? '';
return RemoteControlPage(sn: sn);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/constants/app_constants.dart';
import '../../../core/storage/token_storage.dart';
import '../presentation/connection_controller.dart';
import 'signaling_client.dart';
/// 信令 WebSocket 地址(从 baseUrl 推导)。
///
/// baseUrl 形如 http://host:port/api/,信令端点固定为 /ws/signal(后端新增)。
String get signalServerUrl {
final base = AppConstants.kBaseUrl; // e.g. http://192.168.100.244:8000/api/
try {
final uri = Uri.parse(base);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return '$scheme://${uri.authority}/ws/signal';
} catch (_) {
return 'ws://localhost:8000/ws/signal';
}
}
/// 信令客户端(会话级单例)。
final signalingClientProvider = Provider<SignalingClient>((ref) {
final client = SignalingClient();
ref.onDispose(client.dispose);
return client;
});
/// client 端标识(用于信令 fromDeviceId)。
///
/// 实际身份由后端从 JWT 解析,这里仅作客户端本地标识,取设备 ID 或固定值均可。
final clientNameProvider = Provider<String>((ref) {
final deviceId = TokenStorage.deviceId;
return deviceId.isNotEmpty ? deviceId : 'client';
});
/// 远程控制控制器(按目标设备 SN 隔离,管理连接状态机)。
final connectionControllerProvider =
NotifierProvider<ConnectionController, ConnectionSessionState>(
ConnectionController.new,
);

View File

@@ -0,0 +1,139 @@
import 'dart:typed_data';
/// 控制指令 Action 枚举(与控制端 proto 的 Action 字段号一致)。
class ActionType {
static const unknown = 0;
static const touch = 1;
static const swipe = 2;
static const key = 3;
static const longPress = 4;
static const motionEvent = 5;
static const setResolution = 6;
static const reportResolution = 7;
static const setStreamMode = 8;
static const reportStreamMode = 9;
}
/// protobuf wire format 编码器。
///
/// 与控制端 `com.ttstd.control.ControlMessage`(protobuf 3)字段号完全对齐:
/// action=1, x=2, y=3, x1=4, y1=5, x2=6, y2=7, duration=8,
/// key_code=9, key_action=10, motion_action=11, width=12, height=13,
/// fps=14, stream_mode=15, supported_fps=16(repeated int32)。
///
/// 坐标均为相对屏幕百分比(0.0~1.0),被控端乘以真实分辨率还原像素。
class ControlMessageEncoder {
final int action;
// 单点坐标
final double x;
final double y;
// 滑动起止
final double x1;
final double y1;
final double x2;
final double y2;
final int duration;
// 按键
final int keyCode;
final int keyAction;
// 原始动作
final int motionAction;
// 分辨率/帧率
final int width;
final int height;
final int fps;
final int streamMode;
const ControlMessageEncoder({
required this.action,
this.x = 0,
this.y = 0,
this.x1 = 0,
this.y1 = 0,
this.x2 = 0,
this.y2 = 0,
this.duration = 0,
this.keyCode = 0,
this.keyAction = 0,
this.motionAction = 0,
this.width = 0,
this.height = 0,
this.fps = 0,
this.streamMode = 0,
});
/// 编码为 protobuf 二进制字节数组。
Uint8List encode() {
final out = BytesBuilder(copy: false);
// 1: action (enum=int32)
_writeVarintField(out, 1, action);
// 2: x, 3: y (double, wire type 1)
if (x != 0) _writeFixed64Field(out, 2, x);
if (y != 0) _writeFixed64Field(out, 3, y);
// 4-7: swipe 起止
if (x1 != 0) _writeFixed64Field(out, 4, x1);
if (y1 != 0) _writeFixed64Field(out, 5, y1);
if (x2 != 0) _writeFixed64Field(out, 6, x2);
if (y2 != 0) _writeFixed64Field(out, 7, y2);
// 8: duration (int64)
if (duration != 0) _writeVarintField(out, 8, duration);
// 9: key_code, 10: key_action (int32)
if (keyCode != 0) _writeVarintField(out, 9, keyCode);
if (keyAction != 0) _writeVarintField(out, 10, keyAction);
// 11: motion_action
if (motionAction != 0) _writeVarintField(out, 11, motionAction);
// 12-14: width/height/fps
if (width != 0) _writeVarintField(out, 12, width);
if (height != 0) _writeVarintField(out, 13, height);
if (fps != 0) _writeVarintField(out, 14, fps);
// 15: stream_mode
if (streamMode != 0) _writeVarintField(out, 15, streamMode);
return out.toBytes();
}
// ---- protobuf wire format 原语 ----
void _writeVarintField(BytesBuilder out, int field, int value) {
_writeTag(out, field, 0); // wire type 0 = varint
_writeVarint(out, value);
}
void _writeFixed64Field(BytesBuilder out, int field, double value) {
_writeTag(out, field, 1); // wire type 1 = 64-bit (double)
// protobuf 的 fixed64/double 必须为 little-endian,否则设备端解析出错误值(如 NaN/Infinity)
final bd = ByteData(8);
bd.setFloat64(0, value, Endian.little);
out.add(bd.buffer.asUint8List());
}
void _writeTag(BytesBuilder out, int field, int wireType) {
_writeVarint(out, (field << 3) | wireType);
}
void _writeVarint(BytesBuilder out, int value) {
// 转成无符号 64 位表示,配合逻辑右移 >>>,避免负数算术右移导致死循环(OOM)。
var v = value.toUnsigned(64);
while (true) {
if ((v & ~0x7F) == 0) {
out.addByte(v & 0xFF);
break;
}
out.addByte((v & 0x7F) | 0x80);
v = v >>> 7;
}
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import '../../../core/storage/token_storage.dart';
import '../domain/connection_models.dart';
/// WebRTC 信令 WebSocket 客户端(控制端)。
///
/// 遵循平台原有用户逻辑:控制端为家属端 client,握手时携带
/// `Authorization: Bearer <token>`,由后端信令服务识别 client 身份。
class SignalingClient {
WebSocketChannel? _channel;
StreamSubscription? _subscription;
/// 是否已连接(可复用,供上层判断)。
bool get isConnected => _channel != null;
/// 消息回调:上游处理 OFFER/ANSWER/ICE/REJECTED/OFFLINE 等。
void Function(SignalMessage message)? onMessage;
/// 连接状态回调。
void Function(bool connected)? onConnectionChanged;
void Function(String error)? onError;
/// 建立信令连接。
///
/// [serverUrl] WebSocket 地址(如 ws://host:port/ws/signal)。
/// [targetSn] 目标设备 SN(作为 query 参数传给服务端做绑定校验)。
Future<void> connect(String serverUrl, {String? targetSn}) async {
final token = TokenStorage.accessToken ?? '';
final uri = Uri.parse('$serverUrl?sn=$targetSn');
_channel = IOWebSocketChannel.connect(
uri,
headers: {'Authorization': 'Bearer $token'},
pingInterval: const Duration(seconds: 20),
);
_subscription = _channel!.stream.listen(
(data) => _handleRaw(data),
onError: (Object e) {
debugPrint('[webrtc] 信令错误: $e');
onError?.call('信令连接异常');
},
onDone: () {
debugPrint('[webrtc] 信令连接关闭(serverUrl=$serverUrl)');
onConnectionChanged?.call(false);
},
);
debugPrint('[webrtc] 信令已发起连接: $uri');
}
void _handleRaw(dynamic data) {
if (data is String) {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
final msg = SignalMessage.fromJson(json);
debugPrint('[webrtc] 信令收到: type=${msg.type}, from=${msg.fromDeviceId}, to=${msg.toDeviceId}');
onMessage?.call(msg);
} catch (e) {
debugPrint('[webrtc] 信令解析失败: $e');
}
}
}
/// 发送信令消息。
void send(SignalMessage message) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode(message.toJson()));
}
/// 主动关闭连接。
void dispose() {
_subscription?.cancel();
_subscription = null;
try {
_channel?.sink.close();
} catch (_) {}
_channel = null;
}
}

View File

@@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'protobuf_codec.dart';
import 'signaling_client.dart';
import '../domain/connection_models.dart';
/// WebRTC 编排器(控制端 / Offer 方)。
///
/// - 只收屏幕画面(RECV_ONLY video track)。
/// - 创建 `control_channel` DataChannel,用于向被控端发送触摸/按键指令。
/// - 与信令客户端配合完成 OFFER/ANSWER/ICE 握手。
class WebRtcOrchestrator {
static const _dataChannelLabel = 'control_channel';
// ICE 服务器(与 webrtc_controller_flutter 的 ice_servers.dart 完全一致)。
static const _iceServers = [
{'urls': 'stun:stun.l.google.com:19302'},
{'urls': 'stun:175.178.213.60:3478'},
{
'urls': [
'turns:175.178.213.60:5349',
'turn:175.178.213.60:3478?transport=tcp',
'turn:175.178.213.60:3478',
],
'username': 'fanhuitong',
'credential': 'Fan19961907..',
},
{'urls': 'stun:47.242.112.133:3478'},
{
'urls': 'turn:47.242.112.133:3478',
'username': 'ttstd',
'credential': 'fanhuitong',
},
{
'urls': [
'stun:192.168.5.224:3478',
'turn:192.168.5.224:3478',
],
'username': 'tt',
'credential': 'fht',
},
];
final SignalingClient _signaling;
final String _targetSn;
final String _clientName;
RTCPeerConnection? _pc;
RTCDataChannel? _controlChannel;
/// 远程屏幕渲染器(供页面 RTCVideoView 使用)。
final RTCVideoRenderer remoteRenderer = RTCVideoRenderer();
/// 接收到的被控端报告消息(如 REPORT_RESOLUTION)。
void Function(Uint8List data)? onControlMessage;
/// 连接状态变化(用于 UI 显示 "已连接")。
void Function(bool connected)? onConnectionState;
/// 是否已建立媒体连接。
bool get isConnected =>
_pc?.connectionState == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
WebRtcOrchestrator({
required SignalingClient signaling,
required String targetSn,
required String clientName,
}) : _signaling = signaling,
_targetSn = targetSn,
_clientName = clientName; // ignore: prefer_initializing_formals
/// 发起连接:创建 PeerConnection + DataChannel + 发送 OFFER。
Future<void> start() async {
await _initPeerConnection();
// 创建 control_channel(有序、可靠,保证指令不丢)
final dataChannelInit = RTCDataChannelInit()
..ordered = true
..protocol = _dataChannelLabel;
_controlChannel = await _pc!.createDataChannel(_dataChannelLabel, dataChannelInit);
_controlChannel!.onMessage = (RTCDataChannelMessage message) {
if (message.isBinary) {
onControlMessage?.call(message.binary);
}
};
_controlChannel!.onDataChannelState = (RTCDataChannelState state) {
debugPrint('[webrtc] control_channel state: $state');
};
// 创建 Offer
final offer = await _pc!.createOffer();
await _pc!.setLocalDescription(offer);
// 通过信令发送 OFFER(带 username)
_signaling.send(SignalMessage(
type: SignalType.offer,
fromDeviceId: _clientName,
toDeviceId: _targetSn,
deviceType: 'CONTROLLER',
payload: jsonEncode({
'sdp': offer.sdp,
'username': _clientName,
}),
));
debugPrint('[webrtc] OFFER 已发送 → $_targetSn');
}
Future<void> _initPeerConnection() async {
final config = {
'iceServers': _iceServers,
'sdpSemantics': 'unified-plan',
};
_pc = await createPeerConnection(config);
// 只收屏幕:添加一个 RECV_ONLY video transceiver
final transceiverInit = RTCRtpTransceiverInit();
transceiverInit.direction = TransceiverDirection.RecvOnly;
await _pc!.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: transceiverInit,
);
_pc!.onTrack = (RTCTrackEvent event) {
debugPrint('[webrtc] 收到远程屏幕轨道');
final stream = event.streams.isNotEmpty ? event.streams.first : null;
if (stream != null) {
// RTCVideoRenderer 必须先 initialize() 再设置 srcObject,否则抛
// "Call initialize before setting the stream" 异常,导致画面不渲染。
remoteRenderer.initialize().then((_) {
remoteRenderer.srcObject = stream;
}).catchError((e) {
debugPrint('[webrtc] renderer initialize error: $e');
});
onConnectionState?.call(true);
}
};
_pc!.onConnectionState = (RTCPeerConnectionState state) {
debugPrint('[webrtc] connection state: ${state.name}');
if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
onConnectionState?.call(true);
} else if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected ||
state == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
onConnectionState?.call(false);
}
};
_pc!.onIceCandidate = (RTCIceCandidate candidate) {
_signaling.send(SignalMessage(
type: SignalType.iceCandidate,
fromDeviceId: _clientName,
toDeviceId: _targetSn,
deviceType: 'CONTROLLER',
payload: jsonEncode({
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
'candidate': candidate.candidate,
}),
));
};
}
/// 处理信令消息:ANSWER / ICE_CANDIDATE。
Future<void> handleSignal(SignalMessage message) async {
if (_pc == null) {
debugPrint('[webrtc] handleSignal skipped: pc not ready, type=${message.type}');
return;
}
switch (message.type) {
case SignalType.answer:
final payload = jsonDecode(message.payload ?? '{}') as Map<String, dynamic>;
final sdp = payload['sdp'] as String?;
if (sdp != null) {
debugPrint('[webrtc] 收到 ANSWER,setRemoteDescription');
await _pc!.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
}
break;
case SignalType.iceCandidate:
final payload = jsonDecode(message.payload ?? '{}') as Map<String, dynamic>;
final candidate = RTCIceCandidate(
payload['candidate'] as String? ?? '',
payload['sdpMid'] as String? ?? '',
(payload['sdpMLineIndex'] as num?)?.toInt() ?? 0,
);
await _pc!.addCandidate(candidate);
break;
}
}
/// 发送控制指令(protobuf 二进制)。
void sendCommand(Uint8List bytes) {
if (_controlChannel != null &&
_controlChannel!.state == RTCDataChannelState.RTCDataChannelOpen) {
_controlChannel!.send(RTCDataChannelMessage.fromBinary(bytes));
}
}
/// 发送一次点击(相对坐标 0~1)。
void sendTap(double relX, double relY) {
final bytes = ControlMessageEncoder(
action: ActionType.touch,
x: relX,
y: relY,
).encode();
sendCommand(bytes);
}
/// 发送长按。
void sendLongPress(double relX, double relY) {
final bytes = ControlMessageEncoder(
action: ActionType.longPress,
x: relX,
y: relY,
).encode();
sendCommand(bytes);
}
/// 发送滑动。
void sendSwipe(double relX1, double relY1, double relX2, double relY2, {int duration = 300}) {
final bytes = ControlMessageEncoder(
action: ActionType.swipe,
x1: relX1,
y1: relY1,
x2: relX2,
y2: relY2,
duration: duration,
).encode();
sendCommand(bytes);
}
/// 发送实时触摸事件(MOTION_EVENT,跟手用)。action:0=DOWN,1=UP,2=MOVE。
void sendMotionEvent(int action, double relX, double relY) {
final bytes = ControlMessageEncoder(
action: ActionType.motionEvent,
x: relX,
y: relY,
motionAction: action,
).encode();
sendCommand(bytes);
}
/// 发送按键(keyAction:0=down,1=up;-1=点按)。
void sendKey(int keyCode, {int keyAction = -1}) {
final bytes = ControlMessageEncoder(
action: ActionType.key,
keyCode: keyCode,
keyAction: keyAction,
).encode();
sendCommand(bytes);
}
/// 请求切换被控端采集分辨率。
void sendSetResolution({int width = 0, int height = 0, int fps = 0}) {
final bytes = ControlMessageEncoder(
action: ActionType.setResolution,
width: width,
height: height,
fps: fps,
).encode();
sendCommand(bytes);
}
/// 关闭连接。
Future<void> dispose() async {
try {
if (_controlChannel != null) {
await _controlChannel!.close();
}
if (_pc != null) {
await _pc!.close();
}
await remoteRenderer.dispose();
} catch (e) {
debugPrint('[webrtc] dispose error: $e');
}
_controlChannel = null;
_pc = null;
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart';
/// WebRTC 远程控制会话状态。
enum ConnectionState {
idle,
connecting,
connected,
ended,
error,
}
/// 信令消息类型(与后端 /ws/signal 及移动端 SignalMessage 对齐)。
class SignalType {
static const offer = 'OFFER';
static const answer = 'ANSWER';
static const iceCandidate = 'ICE_CANDIDATE';
static const registerSuccess = 'REGISTER_SUCCESS';
static const connectionRejected = 'CONNECTION_REJECTED';
static const targetOffline = 'TARGET_OFFLINE';
static const requestTimeout = 'REQUEST_TIMEOUT';
static const forceLogout = 'FORCE_LOGOUT';
static const ping = 'PING';
}
/// 信令消息模型。
@immutable
class SignalMessage {
final String type;
final String? fromDeviceId;
final String? toDeviceId;
final String? deviceType;
final String? payload;
const SignalMessage({
required this.type,
this.fromDeviceId,
this.toDeviceId,
this.deviceType,
this.payload,
});
factory SignalMessage.fromJson(Map<String, dynamic> json) {
return SignalMessage(
type: json['type'] as String? ?? '',
fromDeviceId: json['fromDeviceId'] as String?,
toDeviceId: json['toDeviceId'] as String?,
deviceType: json['deviceType'] as String?,
payload: json['payload'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'type': type,
if (fromDeviceId != null) 'fromDeviceId': fromDeviceId,
if (toDeviceId != null) 'toDeviceId': toDeviceId,
if (deviceType != null) 'deviceType': deviceType,
if (payload != null) 'payload': payload,
};
}
}
/// 远程控制会话会话上下文(当前控制的目标设备与连接身份)。
@immutable
class ConnectionSession {
final String targetSn;
final String clientName;
final ConnectionState state;
final String? errorMessage;
const ConnectionSession({
required this.targetSn,
required this.clientName,
this.state = ConnectionState.idle,
this.errorMessage,
});
ConnectionSession copyWith({
ConnectionState? state,
String? errorMessage,
}) {
return ConnectionSession(
targetSn: targetSn,
clientName: clientName,
state: state ?? this.state,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../data/connection_providers.dart';
import '../data/webrtc_orchestrator.dart';
import '../domain/connection_models.dart';
/// 远程控制会话状态(供 UI 渲染)。
class ConnectionSessionState {
final ConnectionState state;
final String? errorMessage;
final bool hasVideo;
const ConnectionSessionState({
this.state = ConnectionState.idle,
this.errorMessage,
this.hasVideo = false,
});
ConnectionSessionState copyWith({
ConnectionState? state,
String? errorMessage,
bool? hasVideo,
}) {
return ConnectionSessionState(
state: state ?? this.state,
errorMessage: errorMessage ?? this.errorMessage,
hasVideo: hasVideo ?? this.hasVideo,
);
}
}
/// 远程控制控制器:管理连接状态机(idle → connecting → connected → ended)。
class ConnectionController extends Notifier<ConnectionSessionState> {
WebRtcOrchestrator? _orchestrator;
@override
ConnectionSessionState build() {
return const ConnectionSessionState();
}
/// 目标设备 SN(页面初始化时设置)。
String? _targetSn;
set targetSn(String? value) => _targetSn = value;
/// 远程屏幕渲染器(供页面 RTCVideoView 渲染)。
RTCVideoRenderer? get remoteRenderer => _orchestrator?.remoteRenderer;
/// 发起连接。
Future<void> connect() async {
final sn = _targetSn;
if (sn == null || sn.isEmpty) {
state = const ConnectionSessionState(
state: ConnectionState.error,
errorMessage: '缺少目标设备',
);
return;
}
final signaling = ref.read(signalingClientProvider);
final clientName = ref.read(clientNameProvider);
state = const ConnectionSessionState(state: ConnectionState.connecting);
_orchestrator = WebRtcOrchestrator(
signaling: signaling,
targetSn: sn,
clientName: clientName,
);
_orchestrator!.onConnectionState = (connected) {
_onRtcState(connected);
};
signaling.onMessage = (message) {
// 先透传给 WebRTC 编排器处理(ANSWER/ICE 等),确保握手能继续
_orchestrator?.handleSignal(message);
// 仅"明确被拒"才进入错误态;TARGET_OFFLINE 可能因握手时序/目标会话短暂未就绪
// 而误报(如设备端刚连上信令、正在应答),不立即判定失败,避免闪断"设备不在线"。
if (message.type == SignalType.connectionRejected) {
state = ConnectionSessionState(
state: ConnectionState.error,
errorMessage: '连接被拒绝',
);
}
};
signaling.onError = (error) {
state = ConnectionSessionState(
state: ConnectionState.error,
errorMessage: error,
);
};
try {
await signaling.connect(signalServerUrl, targetSn: sn);
await _orchestrator!.start();
} catch (e) {
state = ConnectionSessionState(
state: ConnectionState.error,
errorMessage: '连接失败: $e',
);
}
}
void _onRtcState(bool connected) {
if (connected) {
state = const ConnectionSessionState(
state: ConnectionState.connected,
hasVideo: true,
);
} else if (state.state == ConnectionState.connected) {
state = const ConnectionSessionState(state: ConnectionState.ended);
}
}
/// 发送点击(相对坐标 0~1)。
void sendTap(double relX, double relY) {
debugPrint('[touch] TAP rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
_orchestrator?.sendTap(relX, relY);
}
void sendLongPress(double relX, double relY) {
debugPrint('[touch] LONG_PRESS rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
_orchestrator?.sendLongPress(relX, relY);
}
void sendSwipe(double relX1, double relY1, double relX2, double relY2) {
debugPrint('[touch] SWIPE rel=(${relX1.toStringAsFixed(3)}, ${relY1.toStringAsFixed(3)}) -> (${relX2.toStringAsFixed(3)}, ${relY2.toStringAsFixed(3)})');
_orchestrator?.sendSwipe(relX1, relY1, relX2, relY2);
}
/// 发送实时触摸事件(MOTION_EVENT,跟手用)。action:0=DOWN,1=UP,2=MOVE。
void sendMotionEvent(int action, double relX, double relY) {
debugPrint('[touch] MOTION action=$action rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
_orchestrator?.sendMotionEvent(action, relX, relY);
}
void sendKey(int keyCode) {
_orchestrator?.sendKey(keyCode);
}
void sendSetResolution({int width = 0, int height = 0, int fps = 0}) {
_orchestrator?.sendSetResolution(width: width, height: height, fps: fps);
}
/// 关闭并清理。
Future<void> disconnect() async {
await _orchestrator?.dispose();
_orchestrator = null;
ref.read(signalingClientProvider).dispose();
state = const ConnectionSessionState(state: ConnectionState.ended);
}
}

View File

@@ -0,0 +1,260 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
import '../data/connection_providers.dart';
import '../domain/connection_models.dart' as connection;
import 'connection_controller.dart';
/// 远程控制页面:实时观看老人设备屏幕,并通过手势向设备下发控制指令。
///
/// 使用说明:
/// - 单指点击 = 点击
/// - 长按 = 长按
/// - 单指拖动 = 滑动
/// 坐标归一化为 0.0~1.0(相对屏幕),由被控端还原像素。
class RemoteControlPage extends ConsumerStatefulWidget {
final String sn;
const RemoteControlPage({super.key, required this.sn});
@override
ConsumerState<RemoteControlPage> createState() => _RemoteControlPageState();
}
class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
@override
void initState() {
super.initState();
final controller = ref.read(connectionControllerProvider.notifier);
controller.targetSn = widget.sn;
// 等一帧,确保 provider 已就绪
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.connect();
});
}
@override
Widget build(BuildContext context) {
final session = ref.watch(connectionControllerProvider);
final renderer = ref.read(connectionControllerProvider.notifier).remoteRenderer;
final connected = session.state == connection.ConnectionState.connected;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: const Text('远程控制'),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
ref.read(connectionControllerProvider.notifier).disconnect();
Navigator.of(context).maybePop();
},
child: const Text('退出'),
),
),
child: SafeArea(
child: Column(
children: [
Expanded(
child: _buildVideo(renderer, connected, session),
),
_buildControlsBar(session),
],
),
),
);
}
Widget _buildVideo(
RTCVideoRenderer? renderer,
bool connected,
ConnectionSessionState session,
) {
if (session.state == connection.ConnectionState.connecting) {
return const Center(child: CupertinoActivityIndicator());
}
if (session.state == connection.ConnectionState.error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.exclamationmark_circle,
color: CupertinoColors.systemRed, size: 48),
const SizedBox(height: 12),
Text(session.errorMessage ?? '连接失败', textAlign: TextAlign.center),
],
),
);
}
if (!connected || renderer == null) {
return const Center(child: Text('正在等待画面...'));
}
// 远程画面:用 LayoutBuilder 获取视频区域真实尺寸(坐标基准),
// 用 Listener 捕获原始指针事件(避免 GestureDetector 手势竞争导致坐标错乱)。
return LayoutBuilder(
builder: (context, constraints) {
final vw = constraints.maxWidth;
final vh = constraints.maxHeight;
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) => _onPointerDown(e.localPosition, vw, vh),
onPointerMove: (e) => _onPointerMove(e.localPosition, vw, vh),
onPointerUp: (e) => _onPointerUp(e.localPosition, vw, vh),
onPointerCancel: (e) => _onPointerCancel(),
child: Container(
color: Colors.black,
alignment: Alignment.center,
child: RTCVideoView(
renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
),
);
},
);
}
// ---- 基于 Listener 的手势处理(参考源实现 remote_touch_view.dart)----
/// 触摸起点(本地坐标)与按下时间。
Offset? _downPos;
DateTime? _downTime;
/// 长按超时(400ms 后视为长按)。
static const _longPressTimeout = Duration(milliseconds: 400);
/// 点击判定阈值:相对坐标 2%。
static const _tapSlop = 0.02;
/// 实时 MOTION_EVENT 采样节流(16ms)。
static const _sampleIntervalMs = 16;
DateTime? _lastMoveSample;
/// 归一化:本地坐标 / 视频区域尺寸,clamp 0~1。
double _rx(double x, double w) => w <= 0 ? 0 : (x / w).clamp(0.0, 1.0);
double _ry(double y, double h) => h <= 0 ? 0 : (y / h).clamp(0.0, 1.0);
void _onPointerDown(Offset local, double vw, double vh) {
_downPos = local;
_downTime = DateTime.now();
_lastMoveSample = null;
ref.read(connectionControllerProvider.notifier)
.sendMotionEvent(0, _rx(local.dx, vw), _ry(local.dy, vh)); // ACTION_DOWN
}
void _onPointerMove(Offset local, double vw, double vh) {
final down = _downPos;
if (down == null) return;
// 节流采样,减少指令洪泛
final now = DateTime.now();
final last = _lastMoveSample;
if (last != null && now.difference(last).inMilliseconds < _sampleIntervalMs) return;
_lastMoveSample = now;
ref.read(connectionControllerProvider.notifier)
.sendMotionEvent(2, _rx(local.dx, vw), _ry(local.dy, vh)); // ACTION_MOVE
}
void _onPointerUp(Offset local, double vw, double vh) {
final down = _downPos;
final downTime = _downTime;
_downPos = null;
_downTime = null;
_lastMoveSample = null;
if (down == null || vw <= 0 || vh <= 0) return;
final controller = ref.read(connectionControllerProvider.notifier);
final duration = DateTime.now().difference(downTime ?? DateTime.now());
final dx = (_rx(local.dx, vw) - _rx(down.dx, vw)).abs();
final dy = (_ry(local.dy, vh) - _ry(down.dy, vh)).abs();
// 先补发 ACTION_UP,结束触摸
controller.sendMotionEvent(1, _rx(local.dx, vw), _ry(local.dy, vh));
// 位移很小且时间短 → 点击;否则 → 滑动;长按由按下时长判定
if (duration >= _longPressTimeout && dx < _tapSlop && dy < _tapSlop) {
controller.sendLongPress(_rx(down.dx, vw), _ry(down.dy, vh));
} else if (dx < _tapSlop && dy < _tapSlop) {
controller.sendTap(_rx(down.dx, vw), _ry(down.dy, vh));
} else {
controller.sendSwipe(
_rx(down.dx, vw),
_ry(down.dy, vh),
_rx(local.dx, vw),
_ry(local.dy, vh),
);
}
}
void _onPointerCancel() {
_downPos = null;
_downTime = null;
_lastMoveSample = null;
}
Widget _buildControlsBar(ConnectionSessionState session) {
final connected = session.state == connection.ConnectionState.connected;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: CupertinoColors.systemGroupedBackground,
border: Border(top: BorderSide(color: CupertinoColors.separator, width: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CtrlButton(
icon: CupertinoIcons.speaker_slash_fill,
label: '静音',
enabled: connected,
onTap: () {},
),
_CtrlButton(
icon: CupertinoIcons.home,
label: '主页',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(3), // KEYCODE_HOME
),
_CtrlButton(
icon: CupertinoIcons.arrow_left,
label: '返回',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(4), // KEYCODE_BACK
),
_CtrlButton(
icon: CupertinoIcons.rectangle_grid_2x2,
label: '最近',
enabled: connected,
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(187), // KEYCODE_APP_SWITCH
),
],
),
);
}
}
class _CtrlButton extends StatelessWidget {
final IconData icon;
final String label;
final bool enabled;
final VoidCallback onTap;
const _CtrlButton({
required this.icon,
required this.label,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: enabled ? onTap : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 26, color: enabled ? CupertinoColors.activeBlue : CupertinoColors.systemGrey3),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
);
}
}

View File

@@ -822,7 +822,19 @@ class HomeTab extends ConsumerWidget {
label: l10n.cfMirror,
size: 50,
iconSize: 20,
onTap: () => _runDeviceOp(context, ref, l10n, sn, 'mirror'),
// 远程控制:静默触发设备端启动屏幕采集(HTTP mirror 命令,不弹窗),
// 直接进入 WebRTC 远程控制页建立实时画面与控制。
onTap: () async {
if (sn != null && sn.isNotEmpty) {
// 静默下发 mirror,触发设备端启动屏幕采集;失败也不阻断进入控制页
await ref
.read(deviceRepositoryProvider)
.sendDeviceOp(sn, 'mirror');
}
if (context.mounted) {
context.push('/connection');
}
},
),
),
Expanded(

View File

@@ -529,10 +529,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.0.2"
http:
dependency: transitive
description:
@@ -1017,10 +1017,10 @@ packages:
dependency: transitive
description:
name: record_use
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "0.6.0"
riverpod:
dependency: transitive
description: