Files
VibeCoding/webrtc_controller_flutter/lib/main.dart
tongtongstudio 6eb2c7321a feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
2026-08-01 15:13:12 +08:00

1013 lines
34 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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'api/api_client.dart';
import 'config/ice_servers.dart';
import 'controller/remote_controller.dart';
import 'utils/control_commands.dart';
import 'webrtc/self_codec_decoder.dart';
import 'webrtc/video_recorder.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');
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
/// 账号 API 客户端(登录 / 刷新 / 绑定列表 / TURN
final ApiClient _apiClient = ApiClient();
RemoteController? _controller;
RTCVideoRenderer? _renderer;
bool _connected = false;
/// 是否正在连接信令服务器 / 建立 WebRTC连接过程中禁用“连接”按钮
bool _connecting = false;
String _status = '状态: 已停止';
/// 是否已登录accessToken 存在)。
bool _loggedIn = false;
String _stats = '';
double _videoAspect = 16 / 9;
/// 当前串流模式0=WebRTC 全托管1=自编码(自建 MediaCodec 解码)。
int _streamMode = SelfCodecDecoder.streamModeWebRtc;
int? _selfCodecTextureId;
bool _selfCodecReady = false;
bool _selfCodecSupported = true;
/// 远程视频录制器(基于 flutter_webrtc 的 MediaRecorder
final VideoRecorder _videoRecorder = VideoRecorder();
bool _recording = false;
String _recordStatus = '';
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式、待就绪后再开始录制。
bool _pendingRecordStart = false;
/// 分辨率预设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},
];
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
List<int> _fpsOptions = const [15, 24, 30, 60];
/// 被控端当前采集帧率0 表示尚未收到上报)。
int _currentFps = 0;
/// 被控端最近上报的实际采集尺寸仅切帧率时保持分辨率不变0 表示原生)。
int _lastReportedWidth = 0;
int _lastReportedHeight = 0;
@override
void initState() {
super.initState();
// 启动时从安全存储恢复令牌设备ID 由服务端注册后下发,无需本地生成。
_apiClient.restore().then((_) async {
if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) {
setState(() {
_loggedIn = true;
});
}
});
}
@override
void dispose() {
_videoRecorder.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 target = _targetController.text.trim();
if (serverUrl.isEmpty || target.isEmpty) {
_setStatus('请填写服务器地址和目标设备ID');
return;
}
// 连接前确保已登录Bearer token
final authed = await _ensureLoggedIn();
if (!authed) return;
String selectedType = 'NONE';
final valueController = TextEditingController();
// 纵向选项列表,避免分段控件在窄弹窗中把中文选项挤压成两行。
Widget buildOption(
String value,
String title,
String desc,
void Function(void Function()) setDialogState,
) {
final selected = selectedType == value;
return GestureDetector(
onTap: () => setDialogState(() => selectedType = value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
border: Border.all(
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey4,
),
borderRadius: BorderRadius.circular(10),
color: selected
? CupertinoColors.activeBlue.withValues(alpha: 0.06)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
desc,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
selected
? CupertinoIcons.check_mark_circled_solid
: CupertinoIcons.circle,
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey,
),
],
),
),
);
}
await showCupertinoDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => CupertinoAlertDialog(
title: const Text('连接鉴权'),
content: Column(
children: [
const SizedBox(height: 12),
buildOption(
'NONE',
'免密连接',
'被控端弹出手动确认框,无需验证码或密码',
setDialogState,
),
buildOption(
'CODE',
'动态验证码',
'使用一次性动态验证码鉴权',
setDialogState,
),
buildOption(
'PASSWORD',
'固定密码',
'使用固定连接密码鉴权',
setDialogState,
),
const SizedBox(height: 4),
CupertinoTextField(
controller: valueController,
enabled: selectedType != 'NONE',
placeholder: selectedType == 'NONE'
? '免密连接,无需填写'
: 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 target = _targetController.text.trim();
if (serverUrl.isEmpty || target.isEmpty) {
_setStatus('请填写服务器地址和目标设备ID');
return;
}
setState(() => _connecting = true);
_setStatus('状态: 正在连接信令服务器...');
_controller = RemoteController(
serverUrl: serverUrl,
targetDeviceId: target,
apiClient: _apiClient,
token: _apiClient.accessToken,
authType: authType,
authValue: authValue,
);
_controller!.onStatusChanged = _setStatus;
_controller!.onConnectionEstablished = () {
setState(() {
_connected = true;
_connecting = false;
_status = '状态: 已连接 - 远程控制中';
});
};
_controller!.onConnectionFailed = (error) {
if (mounted) setState(() => _connecting = false);
_showAlert('连接失败:$error');
};
_controller!.onDisconnected = () {
setState(() {
_connected = false;
_connecting = 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);
if (_pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onStats = (stats) => setState(() => _stats = stats);
_controller!.onSelfCodecReady = (textureId) {
if (mounted) {
setState(() {
_selfCodecTextureId = textureId;
_selfCodecReady = true;
});
}
};
_controller!.onSelfCodecLost = () {
if (mounted) {
setState(() {
_selfCodecReady = false;
_selfCodecTextureId = null;
});
}
};
_controller!.onStreamModeReport = (mode) {
if (mounted) setState(() => _streamMode = mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && _pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onResolutionReported = (w, h) {
if (w > 0 && h > 0 && mounted) {
final aspect = w / h;
setState(() => _videoAspect = aspect);
_resizeWindowToAspect(aspect);
}
};
// 被控端上报当前帧率与支持的帧率档位 -> 同步帧率菜单
_controller!.onFpsReport = (w, h, fps, supportedFps) {
if (!mounted) return;
setState(() {
if (w > 0) _lastReportedWidth = w;
if (h > 0) _lastReportedHeight = h;
if (fps > 0) _currentFps = fps;
if (supportedFps.isNotEmpty) _fpsOptions = supportedFps;
});
};
_controller!.onSelfCodecNotSupported = () {
if (mounted) setState(() => _selfCodecSupported = false);
_showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
};
_controller!.onTokenExpired = () {
_showAlert('登录已失效,请重新登录后再连接。');
_resetLogin();
};
_controller!.onForceLogout = () {
_showAlert('账号已在其他位置登录,已强制下线。');
_resetLogin();
};
_controller!.connect(authType: authType, authValue: authValue);
}
/// 确保已登录:若已有 refreshToken 则直接返回;否则弹出登录对话框,登录成功后返回。 */
Future<bool> _ensureLoggedIn() async {
if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) {
return true;
}
final login = await _showLoginDialog();
return login;
}
/// 显示登录对话框:输入用户名/密码,调用 /api/auth/login 保存令牌。
Future<bool> _showLoginDialog() async {
_usernameController.clear();
_passwordController.clear();
final result = await showCupertinoDialog<bool>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('登录'),
content: Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
children: [
CupertinoTextField(
controller: _usernameController,
placeholder: '用户名',
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
),
const SizedBox(height: 10),
CupertinoTextField(
controller: _passwordController,
placeholder: '密码',
obscureText: true,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
),
],
),
),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.of(ctx).pop(false),
),
CupertinoDialogAction(
child: const Text('登录'),
onPressed: () async {
final user = _usernameController.text.trim();
final pass = _passwordController.text.trim();
if (user.isEmpty || pass.isEmpty) {
_showAlert('请输入用户名和密码');
return;
}
try {
await _apiClient.login(user, pass);
if (mounted) {
setState(() {
_loggedIn = true;
});
}
Navigator.of(ctx).pop(true);
} catch (e) {
_showAlert('登录失败:$e');
}
},
),
],
),
);
return result ?? false;
}
/// 退出登录并复位 UI 状态(清空令牌、停止连接)。
Future<void> _resetLogin() async {
await _apiClient.clear();
await _disconnect();
if (mounted) {
setState(() {
_loggedIn = false;
_deviceIdController.clear();
});
}
}
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;
_connecting = false;
});
}
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
Future<void> _onConnectionRejected(String message) async {
_showAlert(message);
setState(() {
_connected = false;
_connecting = 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('取消'),
),
),
);
}
/// 弹出帧率选择菜单iOS 风格 ActionSheet仅切帧率分辨率保持不变。
void _showFpsMenu() {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in _fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
if (fps <= 0 || fps == _currentFps) return;
// 分辨率沿用被控端最近上报的实际采集尺寸0 表示原生)
_controller?.sendResolutionChange(
_lastReportedWidth,
_lastReportedHeight,
fps,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == _currentFps) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text('${fps}fps'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 切换串流模式WebRTC 全托管 <-> 自编码(自建 MediaCodec 解码)。
void _toggleStreamMode() {
if (!_selfCodecSupported) {
_showAlert('当前平台不支持自编码硬解。');
return;
}
final next = _streamMode == SelfCodecDecoder.streamModeSelfCodec
? SelfCodecDecoder.streamModeWebRtc
: SelfCodecDecoder.streamModeSelfCodec;
setState(() => _streamMode = next);
_controller?.sendStreamMode(next);
}
/// 切换远程视频录制:开始 / 停止。
Future<void> _toggleRecord() async {
if (_recording) {
await _stopRecording();
return;
}
if (_renderer == null) {
_showAlert('尚未连接或没有视频画面,无法录制');
return;
}
if (_streamMode == SelfCodecDecoder.streamModeSelfCodec) {
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
_pendingRecordStart = true;
setState(() => _recordStatus = '正在切回标准模式以开始录制...');
_controller?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
return;
}
await _startRecording();
}
/// 开始录制当前远端视频轨道。
Future<void> _startRecording() async {
if (_recording || !mounted) return;
final stream = _renderer?.srcObject;
if (stream == null) {
_showAlert('尚未接收到视频画面,无法录制');
return;
}
try {
final ok = await _videoRecorder.start(stream);
if (ok && mounted) {
setState(() {
_recording = true;
_recordStatus = '录制中...';
});
}
} catch (e) {
if (mounted) {
setState(() => _recordStatus = '');
_showAlert('开始录制失败:$e');
}
}
}
/// 停止录制并提示保存位置。
Future<void> _stopRecording() async {
final path = await _videoRecorder.stop();
if (mounted) {
setState(() {
_recording = false;
_recordStatus = path != null ? '已保存:$path' : '录制已停止';
});
}
}
Future<void> _disconnect() async {
await _videoRecorder.dispose();
await _controller?.disconnect();
_controller = null;
_renderer?.removeListener(_onRendererUpdate);
_lastResizedAspect = 0;
setState(() {
_connected = false;
_connecting = false;
_renderer = null;
_status = '状态: 已停止';
_stats = '';
_streamMode = SelfCodecDecoder.streamModeWebRtc;
_selfCodecTextureId = null;
_selfCodecReady = false;
_selfCodecSupported = true;
_recording = false;
_recordStatus = '';
_pendingRecordStart = false;
_currentFps = 0;
_lastReportedWidth = 0;
_lastReportedHeight = 0;
_fpsOptions = const [15, 24, 30, 60];
});
}
@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: 'wss://www.ttstd.com/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: '连接后由服务端下发',
enabled: false,
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: 16),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _loggedIn
? () async {
await _resetLogin();
}
: () async {
final ok = await _showLoginDialog();
if (!ok) _setStatus('请先登录后再连接');
},
child: Text(_loggedIn ? '退出登录' : '登录账号'),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _connecting ? null : _showAuthDialog,
child: Text(_connecting ? '正在连接...' : '连接被控设备'),
),
),
],
),
),
);
}
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
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,
),
),
],
),
),
// 帧率菜单按钮:显示被控端当前采集帧率
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _showFpsMenu,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.speedometer,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
_currentFps > 0 ? '${_currentFps}fps' : '帧率',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _selfCodecSupported ? _toggleStreamMode : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'自编码',
style: TextStyle(
color: _selfCodecSupported
? CupertinoColors.white
: CupertinoColors.white.withValues(alpha: 0.4),
fontSize: 14,
),
),
const SizedBox(width: 6),
CupertinoSwitch(
value: _streamMode == SelfCodecDecoder.streamModeSelfCodec,
onChanged: _selfCodecSupported
? (_) => _toggleStreamMode()
: null,
activeTrackColor: CupertinoColors.activeBlue,
),
],
),
),
// 远程视频录制开关
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _toggleRecord,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_recording
? CupertinoIcons.stop_circle
: CupertinoIcons.video_camera,
color: _recording
? CupertinoColors.destructiveRed
: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
_recording ? '停止' : '录制',
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 (_streamMode == SelfCodecDecoder.streamModeSelfCodec &&
_selfCodecReady &&
_selfCodecTextureId != null)
Center(
child: AspectRatio(
aspectRatio: _videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
Texture(textureId: _selfCodecTextureId!),
RemoteTouchView(
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),
),
),
],
),
),
)
else 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),
),
if (_recordStatus.isNotEmpty)
Text(
_recordStatus,
style: const TextStyle(
color: CupertinoColors.systemOrange,
fontSize: 12,
),
),
],
),
),
),
Positioned(
top: 8,
right: 8,
child: _buildTopMenuBar(),
),
],
),
);
}
}