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

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