build: 控制端功能移植到flutter版本
This commit is contained in:
26
webrtc_controller_flutter/lib/config/ice_servers.dart
Normal file
26
webrtc_controller_flutter/lib/config/ice_servers.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
/// ICE 服务器配置(与 WebRTCController Android 端保持一致)。
|
||||
///
|
||||
/// 注意:TURN 用户名/密码为示例凭据,请按需替换为自己的服务器配置。
|
||||
const List<Map<String, dynamic>> kIceServers = [
|
||||
{
|
||||
'urls': 'turn:175.178.213.60:3478',
|
||||
'username': 'fanhuitong',
|
||||
'credential': 'Fan19961207..',
|
||||
},
|
||||
{
|
||||
'urls': 'turn:192.168.5.224:3478',
|
||||
'username': 'tt',
|
||||
'credential': 'fht',
|
||||
},
|
||||
{'urls': 'stun:175.178.213.60:3478'},
|
||||
{'urls': 'stun:192.168.5.224:3478'},
|
||||
{'urls': 'stun:stun.l.google.com:19302'},
|
||||
{'urls': 'stun:stun1.l.google.com:19302'},
|
||||
{'urls': 'stun:stun2.l.google.com:19302'},
|
||||
];
|
||||
|
||||
/// 信令 WebSocket 默认地址。
|
||||
const String kDefaultSignalServer = 'ws://192.168.100.222:8080/ws/signal';
|
||||
|
||||
/// DataChannel 标签,控制端与被控端需一致。
|
||||
const String kDataChannelLabel = 'control_channel';
|
||||
117
webrtc_controller_flutter/lib/controller/remote_controller.dart
Normal file
117
webrtc_controller_flutter/lib/controller/remote_controller.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../models/signal_message.dart';
|
||||
import '../signaling/signaling_client.dart';
|
||||
import '../webrtc/webrtc_controller.dart';
|
||||
|
||||
/// 控制端编排器:组合信令客户端与 WebRTC 控制器,
|
||||
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
|
||||
class RemoteController {
|
||||
final String serverUrl;
|
||||
final String deviceId;
|
||||
final String targetDeviceId;
|
||||
|
||||
late final SignalingClient _signaling;
|
||||
WebRtcController? _webRtc;
|
||||
Timer? _statsTimer;
|
||||
|
||||
/// 信令状态变化(如“正在连接…”、“已连接…”)。
|
||||
void Function(String status)? onStatusChanged;
|
||||
|
||||
/// WebRTC 连接建立(可开始远程控制)。
|
||||
void Function()? onConnectionEstablished;
|
||||
|
||||
/// 连接断开。
|
||||
void Function()? onDisconnected;
|
||||
|
||||
/// 远端视频渲染器就绪。
|
||||
void Function(RTCVideoRenderer renderer)? onRemoteStream;
|
||||
|
||||
/// 统计信息刷新(每秒一次)。
|
||||
void Function(String stats)? onStats;
|
||||
|
||||
RemoteController({
|
||||
required this.serverUrl,
|
||||
required this.deviceId,
|
||||
required this.targetDeviceId,
|
||||
});
|
||||
|
||||
/// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。
|
||||
void connect() {
|
||||
onStatusChanged?.call('状态: 正在连接信令服务器...');
|
||||
|
||||
_signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId);
|
||||
_signaling.onConnected = () {
|
||||
onStatusChanged?.call('状态: 已连接信令服务器,正在发起连接...');
|
||||
_initWebRtc();
|
||||
};
|
||||
_signaling.onMessage = _handleSignalMessage;
|
||||
_signaling.onDisconnected = () {
|
||||
onStatusChanged?.call('状态: 已断开连接');
|
||||
onDisconnected?.call();
|
||||
};
|
||||
_signaling.onError = (error) {
|
||||
onStatusChanged?.call('状态: 连接错误 - $error');
|
||||
};
|
||||
_signaling.connect();
|
||||
}
|
||||
|
||||
void _initWebRtc() {
|
||||
_webRtc = WebRtcController(
|
||||
signaling: _signaling,
|
||||
deviceId: deviceId,
|
||||
targetDeviceId: targetDeviceId,
|
||||
);
|
||||
_webRtc!.onConnectionEstablished = () {
|
||||
onConnectionEstablished?.call();
|
||||
_startStats();
|
||||
};
|
||||
_webRtc!.onDisconnected = () {
|
||||
onDisconnected?.call();
|
||||
};
|
||||
_webRtc!.onRemoteStream = (renderer) => onRemoteStream?.call(renderer);
|
||||
_webRtc!.initialize().catchError((e) {
|
||||
onStatusChanged?.call('状态: 连接失败 - $e');
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSignalMessage(SignalMessage message) {
|
||||
switch (message.type?.toUpperCase()) {
|
||||
case 'ANSWER':
|
||||
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
|
||||
_webRtc?.handleAnswer(payload['sdp'] as String);
|
||||
break;
|
||||
case 'ICE_CANDIDATE':
|
||||
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
|
||||
_webRtc?.handleIceCandidate(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _startStats() {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
|
||||
final text = await _webRtc?.getStatsText();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
onStats?.call(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 发送控制指令(JSON 字符串)。
|
||||
void sendControlCommand(String commandJson) {
|
||||
_webRtc?.sendControlCommand(commandJson);
|
||||
}
|
||||
|
||||
/// 断开连接并释放资源。
|
||||
Future<void> disconnect() async {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
await _webRtc?.close();
|
||||
_webRtc = null;
|
||||
_signaling.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'config/ice_servers.dart';
|
||||
import 'controller/remote_controller.dart';
|
||||
import 'utils/control_commands.dart';
|
||||
import 'widgets/remote_touch_view.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
@@ -7,116 +14,231 @@ void main() {
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
title: 'WebRTC 控制端',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
home: const ControllerHome(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
class ControllerHome extends StatefulWidget {
|
||||
const ControllerHome({super.key});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
State<ControllerHome> createState() => _ControllerHomeState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
class _ControllerHomeState extends State<ControllerHome> {
|
||||
final _serverUrlController =
|
||||
TextEditingController(text: kDefaultSignalServer);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
|
||||
void _incrementCounter() {
|
||||
RemoteController? _controller;
|
||||
RTCVideoRenderer? _renderer;
|
||||
|
||||
bool _connected = false;
|
||||
String _status = '状态: 已停止';
|
||||
String _stats = '';
|
||||
double _videoAspect = 16 / 9;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deviceIdController.text = 'controller_${const Uuid().v4().substring(0, 8)}';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.disconnect();
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_targetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setStatus(String status) => setState(() => _status = status);
|
||||
|
||||
Future<void> _connect() async {
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final deviceId = _deviceIdController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
|
||||
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
|
||||
_setStatus('请填写所有字段');
|
||||
return;
|
||||
}
|
||||
|
||||
_setStatus('状态: 正在连接信令服务器...');
|
||||
|
||||
_controller = RemoteController(
|
||||
serverUrl: serverUrl,
|
||||
deviceId: deviceId,
|
||||
targetDeviceId: target,
|
||||
);
|
||||
_controller!.onStatusChanged = _setStatus;
|
||||
_controller!.onConnectionEstablished = () {
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_status = '状态: 已连接 - 远程控制中';
|
||||
});
|
||||
};
|
||||
_controller!.onDisconnected = () {
|
||||
setState(() => _connected = false);
|
||||
_setStatus('状态: 远端已断开');
|
||||
};
|
||||
_controller!.onRemoteStream = (renderer) {
|
||||
setState(() => _renderer = renderer);
|
||||
renderer.addListener(_onRendererUpdate);
|
||||
};
|
||||
_controller!.onStats = (stats) => setState(() => _stats = stats);
|
||||
|
||||
_controller!.connect();
|
||||
}
|
||||
|
||||
void _onRendererUpdate() {
|
||||
final w = _renderer?.value.width ?? 0;
|
||||
final h = _renderer?.value.height ?? 0;
|
||||
if (w > 0 && h > 0 && mounted) {
|
||||
setState(() => _videoAspect = w / h);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
await _controller?.disconnect();
|
||||
_controller = null;
|
||||
_renderer?.removeListener(_onRendererUpdate);
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
_connected = false;
|
||||
_renderer = null;
|
||||
_status = '状态: 已停止';
|
||||
_stats = '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
appBar: AppBar(title: const Text('WebRTC 控制端')),
|
||||
body: _connected ? _buildControlPanel() : _buildSetupPanel(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSetupPanel() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'WebRTC 控制端',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('信令服务器地址:', style: TextStyle(fontSize: 14)),
|
||||
TextField(
|
||||
controller: _serverUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'ws://192.168.100.222:8080/ws/signal',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('本机设备ID:', style: TextStyle(fontSize: 14)),
|
||||
TextField(
|
||||
controller: _deviceIdController,
|
||||
decoration: const InputDecoration(hintText: '设备ID'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('目标被控设备ID:', style: TextStyle(fontSize: 14)),
|
||||
TextField(
|
||||
controller: _targetController,
|
||||
decoration: const InputDecoration(hintText: '被控端设备ID'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_status,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _connect,
|
||||
child: const Text('连接被控设备'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlPanel() {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(color: Colors.black),
|
||||
if (_renderer != null)
|
||||
Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: _videoAspect,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RTCVideoView(
|
||||
_renderer!,
|
||||
objectFit:
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||
),
|
||||
RemoteTouchView(
|
||||
onTouch: (x, y) => _controller
|
||||
?.sendControlCommand(ControlCommands.touch(x, y)),
|
||||
onSwipe: (x1, y1, x2, y2, d) => _controller
|
||||
?.sendControlCommand(
|
||||
ControlCommands.swipe(x1, y1, x2, y2, d)),
|
||||
onKey: (k, a) => _controller
|
||||
?.sendControlCommand(ControlCommands.key(k, a)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
_stats,
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: FloatingActionButton(
|
||||
onPressed: _disconnect,
|
||||
tooltip: '断开连接',
|
||||
child: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
61
webrtc_controller_flutter/lib/models/signal_message.dart
Normal file
61
webrtc_controller_flutter/lib/models/signal_message.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 信令消息模型,对应 Android 端的 SignalMessage。
|
||||
///
|
||||
/// 字段含义:
|
||||
/// - [type] 消息类型:REGISTER / OFFER / ANSWER / ICE_CANDIDATE
|
||||
/// - [fromDeviceId] 发送方设备 ID
|
||||
/// - [toDeviceId] 接收方设备 ID
|
||||
/// - [deviceType] 设备类型:CONTROLLER / CONTROLLED
|
||||
/// - [payload] JSON 字符串形式的负载(SDP / ICE 候选等)
|
||||
class SignalMessage {
|
||||
final String? type;
|
||||
final String? fromDeviceId;
|
||||
final String? toDeviceId;
|
||||
final String? deviceType;
|
||||
final String? payload;
|
||||
|
||||
const SignalMessage({
|
||||
this.type,
|
||||
this.fromDeviceId,
|
||||
this.toDeviceId,
|
||||
this.deviceType,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
factory SignalMessage.fromJson(Map<String, dynamic> json) => 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() => {
|
||||
if (type != null) 'type': type,
|
||||
if (fromDeviceId != null) 'fromDeviceId': fromDeviceId,
|
||||
if (toDeviceId != null) 'toDeviceId': toDeviceId,
|
||||
if (deviceType != null) 'deviceType': deviceType,
|
||||
if (payload != null) 'payload': payload,
|
||||
};
|
||||
|
||||
/// 便捷构造方法:payload 为任意 Map,会自动序列化为 JSON 字符串。
|
||||
factory SignalMessage.withPayload({
|
||||
required String type,
|
||||
required String fromDeviceId,
|
||||
required String toDeviceId,
|
||||
required String deviceType,
|
||||
required Map<String, dynamic> payload,
|
||||
}) {
|
||||
return SignalMessage(
|
||||
type: type,
|
||||
fromDeviceId: fromDeviceId,
|
||||
toDeviceId: toDeviceId,
|
||||
deviceType: deviceType,
|
||||
payload: jsonEncode(payload),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => jsonEncode(toJson());
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../models/signal_message.dart';
|
||||
|
||||
/// 信令客户端,封装 WebSocket 连接与消息收发。
|
||||
///
|
||||
/// 连接成功后自动向服务器发送 REGISTER 注册为 CONTROLLER 设备,
|
||||
/// 与 Android 端 WebSocketClient 行为一致。
|
||||
class SignalingClient {
|
||||
final String serverUrl;
|
||||
final String deviceId;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
|
||||
/// 收到信令消息回调(已解析为 SignalMessage)。
|
||||
void Function(SignalMessage message)? onMessage;
|
||||
|
||||
/// 连接成功回调。
|
||||
void Function()? onConnected;
|
||||
|
||||
/// 连接断开回调。
|
||||
void Function()? onDisconnected;
|
||||
|
||||
/// 连接错误回调。
|
||||
void Function(String error)? onError;
|
||||
|
||||
SignalingClient({required this.serverUrl, required this.deviceId});
|
||||
|
||||
/// 建立 WebSocket 连接并注册设备。
|
||||
void connect() {
|
||||
try {
|
||||
final uri = Uri.parse(serverUrl);
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
|
||||
_channel!.stream.listen(
|
||||
_onData,
|
||||
onDone: () => onDisconnected?.call(),
|
||||
onError: (Object e) => onError?.call(e.toString()),
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
// 连接建立后注册设备(与 Android 端 onOpen -> registerDevice 对应)。
|
||||
_register();
|
||||
} catch (e) {
|
||||
onError?.call(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onData(dynamic data) {
|
||||
if (data is! String) return;
|
||||
try {
|
||||
final map = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = SignalMessage.fromJson(map);
|
||||
onMessage?.call(message);
|
||||
} catch (_) {
|
||||
// 忽略无法解析的消息。
|
||||
}
|
||||
}
|
||||
|
||||
void _register() {
|
||||
final msg = SignalMessage(
|
||||
type: 'REGISTER',
|
||||
fromDeviceId: deviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
);
|
||||
_send(msg);
|
||||
// 通知上层已“连接”(WebSocketChannel 会缓冲发送,直到底层连接就绪);
|
||||
// 后续 OFFER 等消息会排在 REGISTER 之后发送,保证服务器先完成注册。
|
||||
onConnected?.call();
|
||||
}
|
||||
|
||||
/// 发送信令消息。
|
||||
void send(SignalMessage message) => _send(message);
|
||||
|
||||
void _send(SignalMessage message) {
|
||||
_channel?.sink.add(jsonEncode(message.toJson()));
|
||||
}
|
||||
|
||||
/// 关闭连接。
|
||||
void disconnect() {
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
bool get isConnected => _channel != null;
|
||||
}
|
||||
40
webrtc_controller_flutter/lib/utils/control_commands.dart
Normal file
40
webrtc_controller_flutter/lib/utils/control_commands.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
|
||||
///
|
||||
/// 指令通过 DataChannel 以 JSON 字符串发送,被控端(WebRTCControlled)
|
||||
/// 的 InputCommandHandler 负责解析执行。
|
||||
class ControlCommands {
|
||||
ControlCommands._();
|
||||
|
||||
/// 单击指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
|
||||
static String touch(double x, double y) => jsonEncode({
|
||||
'action': 'TOUCH',
|
||||
'x': x,
|
||||
'y': y,
|
||||
});
|
||||
|
||||
/// 滑动指令。坐标为相对百分比,duration 为毫秒。
|
||||
static String swipe(
|
||||
double x1,
|
||||
double y1,
|
||||
double x2,
|
||||
double y2,
|
||||
int durationMs,
|
||||
) =>
|
||||
jsonEncode({
|
||||
'action': 'SWIPE',
|
||||
'x1': x1,
|
||||
'y1': y1,
|
||||
'x2': x2,
|
||||
'y2': y2,
|
||||
'duration': durationMs,
|
||||
});
|
||||
|
||||
/// 按键指令。action: 0=按下, 1=抬起(对应 Android KeyEvent.ACTION_DOWN/UP)。
|
||||
static String key(int keyCode, int action) => jsonEncode({
|
||||
'action': 'KEY',
|
||||
'keyCode': keyCode,
|
||||
'keyAction': action,
|
||||
});
|
||||
}
|
||||
221
webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart
Normal file
221
webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../config/ice_servers.dart';
|
||||
import '../models/signal_message.dart';
|
||||
import '../signaling/signaling_client.dart';
|
||||
|
||||
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建 PeerConnection(recvonly 视频 + 控制用 DataChannel)
|
||||
/// - 创建 Offer 并通过信令发送
|
||||
/// - 处理 Answer / ICE 候选
|
||||
/// - 通过 DataChannel 发送控制指令(触摸/滑动/按键)
|
||||
/// - 采集并解析连接统计信息
|
||||
///
|
||||
/// 本类同时兼容 Android 与 iOS(基于 flutter_webrtc)。
|
||||
class WebRtcController {
|
||||
final SignalingClient signaling;
|
||||
final String deviceId;
|
||||
final String targetDeviceId;
|
||||
|
||||
RTCPeerConnection? _pc;
|
||||
RTCDataChannel? _dataChannel;
|
||||
|
||||
/// 远端视频渲染器,由调用方持有并显示。
|
||||
final RTCVideoRenderer renderer = RTCVideoRenderer();
|
||||
|
||||
void Function()? onConnectionEstablished;
|
||||
void Function(String error)? onConnectionFailed;
|
||||
void Function()? onDisconnected;
|
||||
|
||||
/// 远端视频流就绪(renderer 已绑定视频轨道)。
|
||||
void Function(RTCVideoRenderer renderer)? onRemoteStream;
|
||||
|
||||
WebRtcController({
|
||||
required this.signaling,
|
||||
required this.deviceId,
|
||||
required this.targetDeviceId,
|
||||
});
|
||||
|
||||
/// 初始化渲染器、PeerConnection,并创建 Offer。
|
||||
Future<void> initialize() async {
|
||||
await renderer.initialize();
|
||||
|
||||
final configuration = {
|
||||
'iceServers': kIceServers,
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'iceCandidatePoolSize': 10,
|
||||
};
|
||||
|
||||
_pc = await createPeerConnection(configuration);
|
||||
|
||||
_pc!.onIceCandidate = _onIceCandidate;
|
||||
_pc!.onIceConnectionState = _onIceConnectionState;
|
||||
_pc!.onTrack = _onTrack;
|
||||
_pc!.onDataChannel = _onDataChannel;
|
||||
|
||||
// 仅接收远端视频(recvonly)。
|
||||
await _pc!.addTransceiver(
|
||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||||
);
|
||||
|
||||
// 创建控制用 DataChannel(有序)。
|
||||
final dcInit = RTCDataChannelInit()..ordered = true;
|
||||
_dataChannel = await _pc!.createDataChannel(kDataChannelLabel, dcInit);
|
||||
_setupDataChannel(_dataChannel!);
|
||||
|
||||
// 创建并发送 Offer。
|
||||
final constraints = {
|
||||
'mandatory': {
|
||||
'OfferToReceiveVideo': true,
|
||||
'OfferToReceiveAudio': false,
|
||||
},
|
||||
'optional': [],
|
||||
};
|
||||
final offer = await _pc!.createOffer(constraints);
|
||||
await _pc!.setLocalDescription(offer);
|
||||
_sendOffer(offer.sdp!);
|
||||
}
|
||||
|
||||
void _onTrack(RTCTrackEvent event) {
|
||||
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
|
||||
renderer.srcObject = event.streams[0];
|
||||
onRemoteStream?.call(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataChannel(RTCDataChannel channel) {
|
||||
// 被控端也可能主动创建 DataChannel,统一处理。
|
||||
_setupDataChannel(channel);
|
||||
}
|
||||
|
||||
void _setupDataChannel(RTCDataChannel channel) {
|
||||
channel.onMessage = (RTCDataChannelMessage message) {
|
||||
// 处理来自被控端的消息(本控制端主要发送,此处仅做日志记录)。
|
||||
// ignore: avoid_print
|
||||
print('DataChannel message: ${message.text}');
|
||||
};
|
||||
channel.onDataChannelState = (RTCDataChannelState state) {
|
||||
// ignore: avoid_print
|
||||
print('DataChannel state: $state');
|
||||
};
|
||||
}
|
||||
|
||||
void _onIceCandidate(RTCIceCandidate candidate) {
|
||||
final payload = {
|
||||
'sdpMid': candidate.sdpMid,
|
||||
'sdpMLineIndex': candidate.sdpMLineIndex,
|
||||
'candidate': candidate.candidate,
|
||||
};
|
||||
final msg = SignalMessage.withPayload(
|
||||
type: 'ICE_CANDIDATE',
|
||||
fromDeviceId: deviceId,
|
||||
toDeviceId: targetDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: payload,
|
||||
);
|
||||
signaling.send(msg);
|
||||
}
|
||||
|
||||
void _onIceConnectionState(RTCIceConnectionState state) {
|
||||
// ignore: avoid_print
|
||||
print('ICE connection state: $state');
|
||||
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
||||
onConnectionEstablished?.call();
|
||||
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
||||
onDisconnected?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _sendOffer(String sdp) {
|
||||
final payload = {'sdp': sdp};
|
||||
final msg = SignalMessage.withPayload(
|
||||
type: 'OFFER',
|
||||
fromDeviceId: deviceId,
|
||||
toDeviceId: targetDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: payload,
|
||||
);
|
||||
signaling.send(msg);
|
||||
}
|
||||
|
||||
/// 处理来自信令服务器的 Answer。
|
||||
Future<void> handleAnswer(String sdp) async {
|
||||
final answer = RTCSessionDescription(sdp, 'answer');
|
||||
await _pc?.setRemoteDescription(answer);
|
||||
}
|
||||
|
||||
/// 处理来自信令服务器的 ICE 候选。
|
||||
Future<void> handleIceCandidate(Map<String, dynamic> payload) async {
|
||||
final candidate = RTCIceCandidate(
|
||||
payload['candidate'] as String,
|
||||
payload['sdpMid'] as String?,
|
||||
payload['sdpMLineIndex'] as int?,
|
||||
);
|
||||
await _pc?.addCandidate(candidate);
|
||||
}
|
||||
|
||||
/// 通过 DataChannel 发送控制指令(JSON 字符串)。
|
||||
void sendControlCommand(String commandJson) {
|
||||
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||
_dataChannel!.send(RTCDataChannelMessage(commandJson));
|
||||
}
|
||||
}
|
||||
|
||||
/// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats)。
|
||||
Future<String> getStatsText() async {
|
||||
if (_pc == null) return '';
|
||||
final reports = await _pc!.getStats();
|
||||
|
||||
dynamic width = '-';
|
||||
dynamic height = '-';
|
||||
dynamic fps = '-';
|
||||
dynamic delay = '-';
|
||||
dynamic codec = '-';
|
||||
|
||||
for (final report in reports) {
|
||||
final values = report.values;
|
||||
if (report.type == 'inbound-rtp' && values['kind'] == 'video') {
|
||||
width = values['frameWidth'] ?? '-';
|
||||
height = values['frameHeight'] ?? '-';
|
||||
fps = values['framesPerSecond'] ?? '-';
|
||||
|
||||
final codecId = values['codecId'];
|
||||
if (codecId != null) {
|
||||
final codecReport = reports.where((r) => r.id == codecId).firstOrNull;
|
||||
final mime = codecReport?.values['mimeType'];
|
||||
if (mime is String && mime.startsWith('video/')) {
|
||||
codec = mime.substring(6);
|
||||
}
|
||||
}
|
||||
} else if (report.type == 'candidate-pair') {
|
||||
if (values['nominated'] == true) {
|
||||
final rtt = values['currentRoundTripTime'];
|
||||
if (rtt is num) {
|
||||
delay = (rtt * 1000).toStringAsFixed(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n'
|
||||
'解码格式: $codec';
|
||||
}
|
||||
|
||||
/// 释放所有资源。
|
||||
Future<void> close() async {
|
||||
try {
|
||||
await _dataChannel?.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _pc?.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await renderer.dispose();
|
||||
} catch (_) {}
|
||||
_dataChannel = null;
|
||||
_pc = null;
|
||||
}
|
||||
}
|
||||
99
webrtc_controller_flutter/lib/widgets/remote_touch_view.dart
Normal file
99
webrtc_controller_flutter/lib/widgets/remote_touch_view.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 远端触摸控制层(对应 Android 端 RemoteTouchView)。
|
||||
///
|
||||
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
|
||||
/// (0.0 ~ 1.0),以便在不同分辨率的设备间换算。
|
||||
///
|
||||
/// - 短时间内位移很小的抬起 -> 单击(TOUCH)
|
||||
/// - 否则 -> 滑动(SWIPE)
|
||||
/// 同时捕获物理按键事件(KEY),用于外接键盘 / 遥控器场景。
|
||||
///
|
||||
/// 该控件完全基于 Flutter 框架实现,无需任何原生代码,可运行于
|
||||
/// Android、iOS 等平台。
|
||||
class RemoteTouchView extends StatefulWidget {
|
||||
final void Function(double x, double y) onTouch;
|
||||
final void Function(
|
||||
double x1,
|
||||
double y1,
|
||||
double x2,
|
||||
double y2,
|
||||
int durationMs,
|
||||
) onSwipe;
|
||||
final void Function(int keyCode, int action) onKey;
|
||||
|
||||
const RemoteTouchView({
|
||||
super.key,
|
||||
required this.onTouch,
|
||||
required this.onSwipe,
|
||||
required this.onKey,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RemoteTouchView> createState() => _RemoteTouchViewState();
|
||||
}
|
||||
|
||||
class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
Offset? _startPosition;
|
||||
DateTime? _startTime;
|
||||
|
||||
double _toRelativeX(Offset local, Size size) =>
|
||||
(local.dx / size.width).clamp(0.0, 1.0);
|
||||
|
||||
double _toRelativeY(Offset local, Size size) =>
|
||||
(local.dy / size.height).clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
// 捕获物理按键(外接键盘 / 遥控器等)。
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 0);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event is KeyUpEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_startPosition = event.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
final size = context.size;
|
||||
if (_startPosition == null || size == null || _startTime == null) {
|
||||
return;
|
||||
}
|
||||
final startX = _toRelativeX(_startPosition!, size);
|
||||
final startY = _toRelativeY(_startPosition!, size);
|
||||
final endX = _toRelativeX(event.localPosition, size);
|
||||
final endY = _toRelativeY(event.localPosition, size);
|
||||
final duration = DateTime.now().difference(_startTime!).inMilliseconds;
|
||||
|
||||
final dx = (endX - startX).abs();
|
||||
final dy = (endY - startY).abs();
|
||||
|
||||
if (duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||
widget.onTouch(startX, startY);
|
||||
} else {
|
||||
widget.onSwipe(startX, startY, endX, endY, max(duration, 1));
|
||||
}
|
||||
_startPosition = null;
|
||||
_startTime = null;
|
||||
},
|
||||
child: const ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user