Files
VibeCoding/webrtc_controller_flutter/lib/main.dart

361 lines
11 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/cupertino.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 CupertinoApp(
title: 'WebRTC 控制端',
theme: const CupertinoThemeData(primaryColor: CupertinoColors.activeBlue),
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;
bool _connected = false;
String _status = '状态: 已停止';
String _stats = '';
double _videoAspect = 16 / 9;
/// 分辨率预设width 为长边像素0 表示被控端原生分辨率;
/// height 传 0由被控端按屏幕宽高比计算fps 传 0沿用当前帧率
int _selectedResolution = 0;
final List<Map<String, Object>> _resolutionOptions = const [
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
];
@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);
/// 以 iOS 风格弹窗提示用户(替代原 Material 的 SnackBar
void _showAlert(String message) {
if (!mounted) return;
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
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 {
_showAlert(message);
await _disconnect();
}
/// 目标被控端不在线:提示用户并复位到连接设置面板。
Future<void> _onTargetOffline(String message) async {
_showAlert(message);
setState(() => _connected = false);
}
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
Future<void> _onConnectionRejected(String message) async {
_showAlert(message);
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 CupertinoPageScaffold(
// 控制模式(已连接)下隐藏导航栏,避免其半透明浮层遮挡顶部的
// 状态/统计信息浮层;连接设置面板再显示标题栏。
navigationBar: _connected
? null
: const CupertinoNavigationBar(
middle: Text('WebRTC 控制端'),
),
child: _connected ? _buildControlPanel() : _buildSetupPanel(),
);
}
Widget _buildSetupPanel() {
return SafeArea(
child: 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)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _serverUrlController,
placeholder: 'ws://175.178.213.60:8088/ws/signal',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 16),
const Text('本机设备ID:', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _deviceIdController,
placeholder: '设备ID',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 16),
const Text('目标被控设备ID:', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: '被控端设备ID',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 24),
Text(
_status,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _connect,
child: const Text('连接被控设备'),
),
),
],
),
),
);
}
Widget _buildControlPanel() {
return SafeArea(
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
// 以便右下角的断开按钮贴近屏幕边缘。
top: true,
bottom: false,
left: false,
right: false,
child: 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: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _disconnect,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: CupertinoColors.destructiveRed,
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: const Icon(
CupertinoIcons.clear,
color: CupertinoColors.white,
),
),
),
),
Positioned(
bottom: 16,
left: 16,
child: CupertinoSegmentedControl<int>(
groupValue: _selectedResolution,
children: <int, Widget>{
for (int i = 0; i < _resolutionOptions.length; i++)
i: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(_resolutionOptions[i]['label'] as String),
),
},
onValueChanged: (int idx) {
setState(() => _selectedResolution = idx);
final o = _resolutionOptions[idx];
_controller?.sendResolutionChange(
o['width'] as int,
o['height'] as int,
o['fps'] as int,
);
},
),
),
],
),
);
}
}