517 lines
17 KiB
Dart
517 lines
17 KiB
Dart
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<ControllerHome> createState() => _ControllerHomeState();
|
||
}
|
||
|
||
class _ControllerHomeState extends State<ControllerHome> {
|
||
/// 与 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<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> _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 = 'NONE';
|
||
final valueController = TextEditingController();
|
||
|
||
await showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, setDialogState) => CupertinoAlertDialog(
|
||
title: const Text('连接鉴权'),
|
||
content: Column(
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
CupertinoSegmentedControl<String>(
|
||
children: const {
|
||
'NONE': Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||
child: Text('免密连接'),
|
||
),
|
||
'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),
|
||
if (selectedType == 'NONE')
|
||
const Text(
|
||
'免密连接:被控端将弹出手动确认对话框,无需输入验证码或密码。',
|
||
style: TextStyle(fontSize: 13, color: CupertinoColors.systemGrey),
|
||
)
|
||
else
|
||
CupertinoTextField(
|
||
controller: valueController,
|
||
placeholder: selectedType == 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码',
|
||
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 (selectedType != 'NONE' && val.isEmpty) {
|
||
_showAlert('请输入验证码或密码');
|
||
return;
|
||
}
|
||
Navigator.of(ctx).pop();
|
||
_connect(
|
||
authType: selectedType,
|
||
authValue: selectedType == 'NONE' ? '' : val,
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _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<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);
|
||
}
|
||
|
||
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
|
||
void _showResolutionMenu() {
|
||
showCupertinoModalPopup<void>(
|
||
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<void> _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(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|