Files
VibeCoding/webrtc_controller_flutter/lib/main.dart
2026-07-16 14:52:10 +08:00

310 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'config/ice_servers.dart';
import 'controller/remote_controller.dart';
import 'utils/control_commands.dart';
import 'utils/device_utils.dart';
import 'widgets/remote_touch_view.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WebRTC 控制端',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ControllerHome(),
);
}
}
class ControllerHome extends StatefulWidget {
const ControllerHome({super.key});
@override
State<ControllerHome> createState() => _ControllerHomeState();
}
class _ControllerHomeState extends State<ControllerHome> {
final _serverUrlController =
TextEditingController(text: kDefaultSignalServer);
final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
RemoteController? _controller;
RTCVideoRenderer? _renderer;
/// 用于在任意位置(含回调中)弹出提示。
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
bool _connected = false;
String _status = '状态: 已停止';
String _stats = '';
double _videoAspect = 16 / 9;
@override
void initState() {
super.initState();
_initDeviceId();
}
/// 异步获取设备标识并填入设备ID输入框非系统签名使用兜底方案
Future<void> _initDeviceId() async {
final id = await DeviceUtils.getSerialNumber();
if (mounted) {
setState(() => _deviceIdController.text = id);
}
}
@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!.onIceDisconnected = (message) {
_onIceDisconnected(message);
};
_controller!.onTargetOffline = (message) {
_onTargetOffline(message);
};
_controller!.onConnectionRejected = (message) {
_onConnectionRejected(message);
};
_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);
}
}
/// ICE 断开:提示用户并自动返回连接设置面板。
Future<void> _onIceDisconnected(String message) async {
final scaffoldCtx = _scaffoldKey.currentState?.context;
if (mounted && scaffoldCtx != null) {
ScaffoldMessenger.of(scaffoldCtx).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 3),
),
);
}
await _disconnect();
}
/// 目标被控端不在线:提示用户并复位到连接设置面板。
Future<void> _onTargetOffline(String message) async {
final scaffoldCtx = _scaffoldKey.currentState?.context;
if (mounted && scaffoldCtx != null) {
ScaffoldMessenger.of(scaffoldCtx).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 3),
),
);
}
setState(() => _connected = false);
}
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
Future<void> _onConnectionRejected(String message) async {
final scaffoldCtx = _scaffoldKey.currentState?.context;
if (mounted && scaffoldCtx != null) {
ScaffoldMessenger.of(scaffoldCtx).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 3),
),
);
}
setState(() => _connected = false);
}
Future<void> _disconnect() async {
await _controller?.disconnect();
_controller = null;
_renderer?.removeListener(_onRendererUpdate);
setState(() {
_connected = false;
_renderer = null;
_status = '状态: 已停止';
_stats = '';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
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://175.178.213.60:8088/ws/signal',
),
),
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(
// 禁用离散的 TOUCH/SWIPE/LONG_PRESS 指令,改用实时的 onMotionEvent 以解决重复操作问题。
// 原始的动作流已包含完整的触摸过程,被控端系统会自动识别单击、滑动和长按。
onTouch: (x, y) {},
onSwipe: (x1, y1, x2, y2, d) {},
onLongPress: (x, y) {},
onKey: (k, a) => _controller
?.sendControlCommand(ControlCommands.key(k, a)),
onMotionEvent: (a, x, y) => _controller
?.sendControlCommand(ControlCommands.motionEvent(a, x, y)),
),
],
),
),
),
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),
),
),
],
);
}
}