docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构

AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
2026-08-03 16:12:44 +08:00
parent 1918e5738e
commit d5e66a1777
51 changed files with 4711 additions and 1458 deletions

View File

@@ -0,0 +1,369 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../core/utils/control_commands.dart';
import '../../../../core/widgets/remote_touch_view.dart';
import '../../data/self_codec_decoder.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
/// 控制面板页:显示远端视频、触摸控制、顶部菜单栏与状态浮层。
class ControlPage extends ConsumerStatefulWidget {
const ControlPage({super.key});
@override
ConsumerState<ControlPage> createState() => _ControlPageState();
}
class _ControlPageState extends ConsumerState<ControlPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final connection = ref.watch(connectionControllerProvider);
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if (next.alert != null) {
final message = next.alert!;
// 消费一次性提示消息
Future.microtask(() {
ref.read(connectionControllerProvider.notifier).consumeAlert();
});
_showAlert(message);
}
// 断开连接后返回设置页
if ((prev?.connected ?? false) && !next.connected) {
context.go('/');
}
},
);
return CupertinoPageScaffold(
navigationBar: null,
child: SafeArea(
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
// 以便右下角的断开按钮贴近屏幕边缘。
top: true,
bottom: false,
left: false,
right: false,
child: Stack(
children: [
Container(color: CupertinoColors.black),
_buildVideoLayer(connection),
_buildStatusOverlay(connection),
Positioned(
top: 8,
right: 8,
child: _buildTopMenuBar(connection, l10n),
),
],
),
),
);
}
Widget _buildVideoLayer(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
final touchLayer = 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)),
);
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec &&
state.selfCodecReady &&
state.selfCodecTextureId != null) {
return Center(
child: AspectRatio(
aspectRatio: state.videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
Texture(textureId: state.selfCodecTextureId!),
touchLayer,
],
),
),
);
}
if (state.renderer != null) {
return Center(
child: AspectRatio(
aspectRatio: state.videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
RTCVideoView(
state.renderer!,
objectFit:
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
touchLayer,
],
),
),
);
}
return const SizedBox.shrink();
}
Widget _buildStatusOverlay(ConnectionSessionState state) {
return Positioned(
top: 0,
left: 0,
child: Container(
color: CupertinoColors.black.withValues(alpha: 0.54),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.status, style: const TextStyle(color: CupertinoColors.white)),
Text(
state.stats,
style: const TextStyle(color: CupertinoColors.white, fontSize: 12),
),
if (state.recordStatus.isNotEmpty)
Text(
state.recordStatus,
style: const TextStyle(
color: CupertinoColors.systemOrange,
fontSize: 12,
),
),
],
),
),
);
}
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
Widget _buildTopMenuBar(ConnectionSessionState state, AppLocalizations l10n) {
final controller = ref.read(connectionControllerProvider.notifier);
final label = state.resolutionOptions[state.selectedResolution]['label']
as String;
return Container(
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.54),
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(state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.slider_horizontal_3,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 帧率菜单按钮:显示被控端当前采集帧率
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () => _showFpsMenu(state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.speedometer,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
state.currentFps > 0 ? '${state.currentFps}fps' : '帧率',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: state.selfCodecSupported ? controller.toggleStreamMode : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.selfCodec,
style: TextStyle(
color: state.selfCodecSupported
? CupertinoColors.white
: CupertinoColors.white.withValues(alpha: 0.4),
fontSize: 14,
),
),
const SizedBox(width: 6),
CupertinoSwitch(
value: state.streamMode ==
SelfCodecDecoder.streamModeSelfCodec,
onChanged: state.selfCodecSupported
? (_) => controller.toggleStreamMode()
: null,
activeTrackColor: CupertinoColors.activeBlue,
),
],
),
),
// 远程视频录制开关
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: controller.toggleRecord,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
state.recording
? CupertinoIcons.stop_circle
: CupertinoIcons.video_camera,
color: state.recording
? CupertinoColors.destructiveRed
: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
state.recording ? l10n.stop : l10n.record,
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: () async {
await ref.read(connectionControllerProvider.notifier).disconnect();
if (mounted) context.go('/');
},
child: const Icon(
CupertinoIcons.xmark_circle_fill,
color: CupertinoColors.destructiveRed,
size: 24,
),
),
],
),
);
}
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
void _showResolutionMenu(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换分辨率'),
actions: [
for (int i = 0; i < state.resolutionOptions.length; i++)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectResolution(i);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == state.selectedResolution) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text(state.resolutionOptions[i]['label'] as String),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。
void _showFpsMenu(ConnectionSessionState state) {
final controller = ref.read(connectionControllerProvider.notifier);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in state.fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
controller.selectFps(fps);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == state.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('取消'),
),
),
);
}
void _showAlert(String message) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../app/constants/app_constants.dart';
import '../../../auth/presentation/auth_controller.dart';
import '../../../auth/presentation/widgets/login_dialog.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
import '../widgets/auth_dialog.dart';
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
class SetupPage extends ConsumerStatefulWidget {
const SetupPage({super.key});
@override
ConsumerState<SetupPage> createState() => _SetupPageState();
}
class _SetupPageState extends ConsumerState<SetupPage> {
final _serverUrlController = TextEditingController(
text: kDefaultSignalServer,
);
final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
@override
void dispose() {
_serverUrlController.dispose();
_deviceIdController.dispose();
_targetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState = ref.watch(authControllerProvider).valueOrNull;
final connection = ref.watch(connectionControllerProvider);
// 连接建立后跳转控制页。
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if ((prev?.connected ?? false) == false && next.connected) {
context.go('/control');
}
},
);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.appTitle),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.appTitle,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _serverUrlController,
placeholder: l10n.serverUrlPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _deviceIdController,
placeholder: l10n.deviceIdPlaceholder,
enabled: false,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: l10n.targetDeviceIdPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 24),
Text(
connection.status,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: (authState?.loggedIn ?? false)
? () async {
await ref
.read(authControllerProvider.notifier)
.logout();
}
: () async {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (_) => const LoginDialog(),
);
if (ok != true && mounted) {
ref.read(connectionControllerProvider.notifier)
.consumeAlert();
}
},
child: Text(
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: connection.connecting ? null : _onConnectPressed,
child: Text(
connection.connecting ? l10n.connecting : l10n.connectDevice,
),
),
),
],
),
),
),
);
}
Future<void> _onConnectPressed() async {
final l10n = AppLocalizations.of(context);
final serverUrl = _serverUrlController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || target.isEmpty) {
_showAlert(l10n.serverAndTargetRequired);
return;
}
// 连接前确保已登录(Bearer token)。
final authState = ref.read(authControllerProvider).valueOrNull;
final loggedIn = authState?.loggedIn ?? false;
if (!loggedIn) {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (_) => const LoginDialog(),
);
if (ok != true || !mounted) return;
}
final selection = await showAuthDialog(context);
if (selection == null || !mounted) return;
await ref.read(connectionControllerProvider.notifier).connect(
serverUrl: serverUrl,
targetDeviceId: target,
authType: selection.type,
authValue: selection.value,
);
}
void _showAlert(String message) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
}