build: 控制端功能移植到flutter版本

This commit is contained in:
2026-07-14 02:02:05 +08:00
parent 5f024be736
commit e260c02629
24 changed files with 1278 additions and 118 deletions

View File

@@ -15,24 +15,9 @@ migration:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: ios
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: linux
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: macos
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: web
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: windows
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section

View File

@@ -1,17 +1,66 @@
# webrtc_controller_flutter
# WebRTC 控制端 (webrtc_controller_flutter)
A new Flutter project.
将原生 Android 项目 **WebRTCController** 的功能移植到 Flutter使用**跨平台**依赖,可同时运行于 **Android****iOS**
## Getting Started
## 功能
This project is a starting point for a Flutter application.
对应原 Android 控制端WebRTCController的能力
A few resources to get you started if this is your first Flutter project:
- 通过 WebSocket 连接信令服务器并注册为 `CONTROLLER` 设备
- 创建 WebRTC 连接(仅接收远端视频 `recvonly` + 一条控制用 DataChannel
- 显示被控端画面(远端视频流)
- 通过触摸 / 滑动 / 物理按键采集输入转换为相对坐标0.0~1.0)经 DataChannel 发送控制指令TOUCH / SWIPE / KEY
- 实时显示连接统计(分辨率、帧率、延迟、解码格式)
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
## 运行依赖(跨平台)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
| 包 | 用途 |
| --- | --- |
| `flutter_webrtc` | WebRTCAndroid + iOS 统一封装) |
| `web_socket_channel` | 信令 WebSocket |
| `uuid` | 生成本机设备 ID |
> 以上包均支持 Android 与 iOS无需编写任何平台原生代码。
## 目录结构
```
lib/
├── config/ice_servers.dart # ICE/TURN/STUN 配置与默认信令地址
├── models/signal_message.dart # 信令消息模型
├── signaling/signaling_client.dart# WebSocket 信令客户端
├── webrtc/webrtc_controller.dart # WebRTC 连接 / DataChannel / 视频渲染 / 统计
├── controller/remote_controller.dart # 编排:信令 + WebRTC 流程
├── utils/control_commands.dart # 控制指令 JSON 构造
├── widgets/remote_touch_view.dart # 触摸/按键采集(纯 Flutter跨平台
└── main.dart # UI设置面板 + 控制面板)
```
## 运行
```bash
flutter pub get
flutter run # 默认运行到已连接设备
flutter run -d android
flutter run -d ios # 需 macOS + Xcode
```
### iOS 注意事项
- 本项目已补齐 `ios/Podfile`(基于当前 Flutter 版本官方模板)。
在 macOS 上首次构建会自动执行 `pod install` 拉取 `flutter_webrtc`
- 为支持开发环境的 `ws://` 明文信令,`ios/Runner/Info.plist` 已添加
`NSAppTransportSecurity -> NSAllowsArbitraryLoads = true`(生产环境建议改用 `wss://`)。
### Android 注意事项
- `android/app/src/main/AndroidManifest.xml` 已添加 `INTERNET` 权限与
`android:usesCleartextTraffic="true"`(支持 `ws://`)。
## 对接说明
- **信令服务器**:需与 WebRTCControlled 项目共用同一套信令协议
REGISTER / OFFER / ANSWER / ICE_CANDIDATEpayload 为 JSON 字符串)。
- **被控端**:使用 WebRTCControlledAndroid接收 OFFER 并回传 ANSWER
`InputCommandHandler` 解析 TOUCH / SWIPE / KEY 指令。
- **ICE 服务器**:见 `lib/config/ice_servers.dart`,请按需替换为自己的 TURN 凭据。

View File

@@ -1,5 +1,6 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}

View File

@@ -1,8 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 远程控制需要通过网络(含 ws:// 明文)连接信令服务器 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:label="webrtc_controller_flutter"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,4 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.java.home=C\:/Program Files/Java/jdk-21
kotlin.daemon.jvm.options=-Xmx4G
kotlin.compiler.execution.strategy=daemon
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -26,6 +26,12 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- 允许 ws:// 等非 TLS 明文连接(开发环境信令服务器通常使用 ws:// -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View 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';

View 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();
}
}

View File

@@ -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),
),
),
],
);
}
}

View 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());
}

View File

@@ -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;
}

View 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,
});
}

View 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
///
/// 职责:
/// - 创建 PeerConnectionrecvonly 视频 + 控制用 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;
}
}

View 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(),
),
),
);
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_webrtc
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import flutter_webrtc
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
}

View File

@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -33,6 +41,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -41,6 +57,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -49,6 +73,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.8.1"
fake_async:
dependency: transitive
description:
@@ -57,6 +89,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -75,6 +123,46 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: "0f89dee7f4c35dab5611f351c3897a3bfe9f7057720cc7da209b52455af4316e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -107,6 +195,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
logger:
dependency: transitive
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -131,6 +235,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.18.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@@ -139,6 +259,86 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
sky_engine:
dependency: transitive
description: flutter
@@ -176,6 +376,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.1"
term_glyph:
dependency: transitive
description:
@@ -192,6 +400,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -208,6 +432,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.38.4"

View File

@@ -35,6 +35,15 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# 跨平台 WebRTC同时支持 Android 与 iOS
flutter_webrtc: ^1.5.2
# WebSocket 信令通信
web_socket_channel: ^3.0.3
# 设备 ID 生成
uuid: ^4.4.0
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_webrtc
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)