build: 控制端功能移植到flutter版本
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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` | WebRTC(Android + 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_CANDIDATE,payload 为 JSON 字符串)。
|
||||
- **被控端**:使用 WebRTCControlled(Android)接收 OFFER 并回传 ANSWER,
|
||||
其 `InputCommandHandler` 解析 TOUCH / SWIPE / KEY 指令。
|
||||
- **ICE 服务器**:见 `lib/config/ice_servers.dart`,请按需替换为自己的 TURN 凭据。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
43
webrtc_controller_flutter/ios/Podfile
Normal file
43
webrtc_controller_flutter/ios/Podfile
Normal 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
|
||||
@@ -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>
|
||||
|
||||
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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_webrtc
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_webrtc
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_webrtc
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user