import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.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 createState() => _ControllerHomeState(); } class _ControllerHomeState extends State { /// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。 static const MethodChannel _windowChannel = MethodChannel('app/window'); /// 记录上一次已应用的视频宽高比,避免重复调整窗口。 double _lastResizedAspect = 0; 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> _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 _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( context: context, builder: (ctx) => CupertinoAlertDialog( content: Text(message), actions: [ CupertinoDialogAction( child: const Text('确定'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); } Future _showAuthDialog() 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; } String selectedType = 'CODE'; final valueController = TextEditingController(); await showCupertinoDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => CupertinoAlertDialog( title: const Text('连接鉴权'), content: Column( children: [ const SizedBox(height: 12), CupertinoSegmentedControl( children: const { 'CODE': Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Text('动态验证码'), ), 'PASSWORD': Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Text('固定密码'), ), }, groupValue: selectedType, onValueChanged: (v) => setDialogState(() => selectedType = v), ), const SizedBox(height: 12), CupertinoTextField( controller: valueController, placeholder: '请输入验证码或密码', obscureText: true, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), ], ), actions: [ CupertinoDialogAction( child: const Text('取消'), onPressed: () => Navigator.of(ctx).pop(), ), CupertinoDialogAction( child: const Text('连接'), onPressed: () { final val = valueController.text.trim(); if (val.isEmpty) { _showAlert('请输入验证码或密码'); return; } Navigator.of(ctx).pop(); _connect(authType: selectedType, authValue: val); }, ), ], ), ), ); } Future _connect({required String authType, required String authValue}) 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, authType: authType, authValue: authValue, ); _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(authType: authType, authValue: authValue); } void _onRendererUpdate() { final w = _renderer?.value.width ?? 0; final h = _renderer?.value.height ?? 0; if (w > 0 && h > 0 && mounted) { final aspect = w / h; setState(() => _videoAspect = aspect); _resizeWindowToAspect(aspect); } } /// Windows 端:保持窗口宽度不变,按视频宽高比调整窗口高度; /// 若高度超出屏幕,由原生端改为合适的窗口大小。 void _resizeWindowToAspect(double aspect) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.windows) return; if (aspect <= 0) return; if ((aspect - _lastResizedAspect).abs() < 0.001) return; _lastResizedAspect = aspect; _windowChannel.invokeMethod('resizeToVideoAspect', aspect); } /// ICE 断开:提示用户并自动返回连接设置面板。 Future _onIceDisconnected(String message) async { _showAlert(message); await _disconnect(); } /// 目标被控端不在线:提示用户并复位到连接设置面板。 Future _onTargetOffline(String message) async { _showAlert(message); setState(() => _connected = false); } /// 被控端拒绝连接请求:提示用户并复位到连接设置面板。 Future _onConnectionRejected(String message) async { _showAlert(message); setState(() => _connected = false); } /// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。 void _showResolutionMenu() { showCupertinoModalPopup( context: context, builder: (ctx) => CupertinoActionSheet( title: const Text('切换分辨率'), actions: [ for (int i = 0; i < _resolutionOptions.length; i++) CupertinoActionSheetAction( onPressed: () { Navigator.of(ctx).pop(); setState(() => _selectedResolution = i); final o = _resolutionOptions[i]; _controller?.sendResolutionChange( o['width'] as int, o['height'] as int, o['fps'] as int, ); }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (i == _selectedResolution) ...[ const Icon(CupertinoIcons.check_mark, size: 18, color: CupertinoColors.activeBlue), const SizedBox(width: 8), ], Text(_resolutionOptions[i]['label'] as String), ], ), ), ], cancelButton: CupertinoActionSheetAction( isDefaultAction: true, onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消'), ), ), ); } Future _disconnect() async { await _controller?.disconnect(); _controller = null; _renderer?.removeListener(_onRendererUpdate); _lastResizedAspect = 0; 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: _showAuthDialog, child: const Text('连接被控设备'), ), ), ], ), ), ); } /// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。 Widget _buildTopMenuBar() { return Container( decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(22), ), padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ // 分辨率菜单按钮:显示当前分辨率标签 CupertinoButton( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), onPressed: _showResolutionMenu, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(CupertinoIcons.slider_horizontal_3, color: CupertinoColors.white, size: 20), const SizedBox(width: 6), Text( _resolutionOptions[_selectedResolution]['label'] as String, style: const TextStyle( color: CupertinoColors.white, fontSize: 14, ), ), ], ), ), // 分隔线 Container( width: 1, height: 22, color: CupertinoColors.white.withValues(alpha: 0.3), ), // 断开连接按钮 CupertinoButton( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), onPressed: _disconnect, child: const Icon( CupertinoIcons.xmark_circle_fill, color: CupertinoColors.destructiveRed, size: 24, ), ), ], ), ); } 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( top: 8, right: 8, child: _buildTopMenuBar(), ), ], ), ); } }