From d5e66a1777c26dbcffe3c1d9197c7ba7ce6b30a7 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Mon, 3 Aug 2026 16:12:44 +0800 Subject: [PATCH] =?UTF-8?q?docs(webrtc=5Fcontroller=5Fflutter):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3=E4=BB=A5?= =?UTF-8?q?=E5=8F=8D=E6=98=A0=E9=87=8D=E6=9E=84=E5=90=8E=E7=9A=84=E6=9E=B6?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。 --- webrtc_controller_flutter/AGENTS.md | 117 +- webrtc_controller_flutter/README.md | 60 +- webrtc_controller_flutter/l10n.yaml | 5 + .../lib/api/api_client.dart | 158 --- webrtc_controller_flutter/lib/app/app.dart | 30 + .../lib/app/constants/app_constants.dart | 24 + .../constants}/ice_servers.dart | 10 - .../lib/app/router/app_router.dart | 21 + .../lib/app/theme/app_theme.dart | 11 + .../lib/core/network/api_exception.dart | 9 + .../{ => core}/proto/control_message.pb.dart | 0 .../proto/control_message.pbenum.dart | 0 .../proto/control_message.pbjson.dart | 0 .../lib/core/storage/token_storage.dart | 33 + .../{ => core}/utils/control_commands.dart | 4 +- .../lib/{ => core}/utils/device_utils.dart | 0 .../{ => core}/widgets/remote_touch_view.dart | 6 +- .../features/auth/data/auth_providers.dart | 9 + .../auth/data/dio_auth_repository.dart | 160 +++ .../features/auth/domain/auth_repository.dart | 28 + .../lib/features/auth/domain/auth_state.dart | 14 + .../auth/domain/auth_state.freezed.dart | 214 ++++ .../auth/presentation/auth_controller.dart | 56 + .../auth/presentation/auth_controller.g.dart | 28 + .../presentation/widgets/login_dialog.dart | 97 ++ .../connection/data}/remote_controller.dart | 29 +- .../connection/data}/self_codec_decoder.dart | 0 .../connection/data}/signaling_client.dart | 7 +- .../connection/data}/video_recorder.dart | 2 +- .../connection/data}/webrtc_controller.dart | 16 +- .../domain/connection_session_state.dart | 72 ++ .../connection_session_state.freezed.dart | 742 ++++++++++++ .../connection/domain/signal_message.dart | 58 + .../domain/signal_message.freezed.dart | 309 +++++ .../connection/domain/signal_message.g.dart | 29 + .../presentation/connection_controller.dart | 277 +++++ .../presentation/connection_controller.g.dart | 29 + .../presentation/pages/control_page.dart | 369 ++++++ .../presentation/pages/setup_page.dart | 197 ++++ .../presentation/widgets/auth_dialog.dart | 185 +++ webrtc_controller_flutter/lib/l10n/app_en.arb | 67 ++ .../lib/l10n/app_localizations.dart | 518 +++++++++ .../lib/l10n/app_localizations_en.dart | 228 ++++ .../lib/l10n/app_localizations_zh.dart | 220 ++++ webrtc_controller_flutter/lib/l10n/app_zh.arb | 67 ++ webrtc_controller_flutter/lib/main.dart | 1014 +---------------- .../lib/models/signal_message.dart | 75 -- .../Flutter/GeneratedPluginRegistrant.swift | 2 + webrtc_controller_flutter/pubspec.lock | 433 ++++++- webrtc_controller_flutter/pubspec.yaml | 98 +- .../test/widget_test.dart | 32 +- 51 files changed, 4711 insertions(+), 1458 deletions(-) create mode 100644 webrtc_controller_flutter/l10n.yaml delete mode 100644 webrtc_controller_flutter/lib/api/api_client.dart create mode 100644 webrtc_controller_flutter/lib/app/app.dart create mode 100644 webrtc_controller_flutter/lib/app/constants/app_constants.dart rename webrtc_controller_flutter/lib/{config => app/constants}/ice_servers.dart (82%) create mode 100644 webrtc_controller_flutter/lib/app/router/app_router.dart create mode 100644 webrtc_controller_flutter/lib/app/theme/app_theme.dart create mode 100644 webrtc_controller_flutter/lib/core/network/api_exception.dart rename webrtc_controller_flutter/lib/{ => core}/proto/control_message.pb.dart (100%) rename webrtc_controller_flutter/lib/{ => core}/proto/control_message.pbenum.dart (100%) rename webrtc_controller_flutter/lib/{ => core}/proto/control_message.pbjson.dart (100%) create mode 100644 webrtc_controller_flutter/lib/core/storage/token_storage.dart rename webrtc_controller_flutter/lib/{ => core}/utils/control_commands.dart (94%) rename webrtc_controller_flutter/lib/{ => core}/utils/device_utils.dart (100%) rename webrtc_controller_flutter/lib/{ => core}/widgets/remote_touch_view.dart (99%) create mode 100644 webrtc_controller_flutter/lib/features/auth/data/auth_providers.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart rename webrtc_controller_flutter/lib/{controller => features/connection/data}/remote_controller.dart (93%) rename webrtc_controller_flutter/lib/{webrtc => features/connection/data}/self_codec_decoder.dart (100%) rename webrtc_controller_flutter/lib/{signaling => features/connection/data}/signaling_client.dart (91%) rename webrtc_controller_flutter/lib/{webrtc => features/connection/data}/video_recorder.dart (97%) rename webrtc_controller_flutter/lib/{webrtc => features/connection/data}/webrtc_controller.dart (98%) create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/signal_message.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/signal_message.freezed.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/domain/signal_message.g.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart create mode 100644 webrtc_controller_flutter/lib/features/connection/presentation/widgets/auth_dialog.dart create mode 100644 webrtc_controller_flutter/lib/l10n/app_en.arb create mode 100644 webrtc_controller_flutter/lib/l10n/app_localizations.dart create mode 100644 webrtc_controller_flutter/lib/l10n/app_localizations_en.dart create mode 100644 webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart create mode 100644 webrtc_controller_flutter/lib/l10n/app_zh.arb delete mode 100644 webrtc_controller_flutter/lib/models/signal_message.dart diff --git a/webrtc_controller_flutter/AGENTS.md b/webrtc_controller_flutter/AGENTS.md index dc75fe2..063a8d8 100644 --- a/webrtc_controller_flutter/AGENTS.md +++ b/webrtc_controller_flutter/AGENTS.md @@ -12,7 +12,7 @@ - **路由:** `go_router` - **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化) - **数据模型:** `freezed` + `build_runner` -- **本地存储:** `hive` / `shared_preferences` +- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌) - **依赖注入:** Riverpod Providers(不使用 GetIt) - **国际化:** `flutter_localizations`(l10n / ARB 格式) @@ -24,33 +24,28 @@ ```text lib/ +├── main.dart # 应用程序入口(ProviderScope + runApp) ├── app/ # 全局应用配置 -│ ├── app.dart # MaterialApp 入口 +│ ├── app.dart # CupertinoApp.router 入口 │ ├── router/ # 路由定义(GoRouter) │ ├── theme/ # 主题、颜色、排版 -│ └── constants/ # 全局常量、API 端点 -│ +│ └── constants/ # 全局常量、API 端点、ICE 配置 ├── core/ # 跨特性共享代码 -│ ├── network/ # Dio 客户端、拦截器、错误处理 +│ ├── network/ # dio 客户端、错误处理 +│ ├── proto/ # Protobuf 生成代码(控制指令协议) │ ├── storage/ # 本地存储辅助工具 │ ├── utils/ # 辅助函数、扩展方法 -│ └── widgets/ # 共享 UI 组件(按钮、输入框) -│ +│ └── widgets/ # 共享 UI 组件(触摸层) ├── features/ # 特性模块 -│ ├── auth/ # 示例:认证模块 -│ │ ├── data/ # 数据源、API 端点、DTO -│ │ ├── domain/ # 领域模型、仓库接口 -│ │ └── presentation/ # UI 页面、组件、控制器 -│ │ ├── controllers/ -│ │ ├── pages/ -│ │ └── widgets/ -│ └── home/ # 示例:首页模块 -│ ├── data/ -│ ├── domain/ -│ └── presentation/ -│ -├── l10n/ # 国际化(.arb 文件) -└── main.dart # 应用入口 +│ ├── auth/ # 登录 / 令牌 / 绑定列表 +│ │ ├── data/ # Repository 实现(dio) +│ │ ├── domain/ # 仓库接口、状态模型(freezed) +│ │ └── presentation/ # 控制器、登录对话框 +│ └── connection/ # 信令 / WebRTC / 控制面板 +│ ├── data/ # 信令客户端、WebRTC、编排器、解码器、录制器 +│ ├── domain/ # 信令消息、会话状态(freezed) +│ └── presentation/ # 控制器、设置页、控制页、鉴权对话框 +└── l10n/ # 国际化(.arb 文件) ``` --- @@ -63,16 +58,18 @@ UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。 - 只能通过 `ref.watch` 或 `ref.listen` 消费状态。 - 禁止在 `onPressed` 或 `build()` 中直接调用 API 或编写业务逻辑。 +- 一次性提示通过状态中的 `alert` 字段传递,UI 展示后调用 `consumeAlert()` 消费。 ### 领域层(`domain/`) -纯 Dart 领域实体(`@freezed`)和仓库接口定义。 +纯 Dart 实体(`freezed`)和仓库接口定义。 -- 零 Flutter/UI 依赖。 +- 零 Flutter/UI 依赖(协议模型除外)。 +- 状态类命名避免与 Flutter SDK 冲突(如用 `ConnectionSessionState` 而非 `ConnectionState`)。 ### 数据层(`data/`) -实现仓库接口,通过 Dio 处理 API 请求,将 JSON DTO 映射为领域实体。 +实现仓库接口,通过 dio 处理 API 请求,将 JSON DTO 映射为领域实体。 --- @@ -81,52 +78,36 @@ UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。 ### 状态管理(Riverpod) - 使用 `@riverpod` 注解 + `build_runner` 代码生成。 +- 会话级控制器(如登录、连接)使用 `@Riverpod(keepAlive: true)`,避免页面切换时销毁。 - 异步操作优先使用 `AsyncNotifierProvider`,配合 `AsyncValue`(Loading / Data / Error)。 - -```dart -@riverpod -class UserProfileController extends _$UserProfileController { - @override - FutureOr build() async { - return _fetchUser(); - } - - Future updateName(String newName) async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard( - () => ref.read(userRepositoryProvider).updateName(newName), - ); - } -} -``` +- 业务回调(信令/WebRTC 事件)统一在控制器内映射到状态,不在 UI 层直接持有控制器实例。 ### 数据建模(freezed) -所有数据类/实体必须用 `freezed` 实现不可变,并配置嵌套 JSON 映射: +所有数据类/实体必须用 `freezed` 实现不可变,并配置 JSON 序列化: ```dart -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'user_model.freezed.dart'; -part 'user_model.g.dart'; - @freezed -class UserModel with _$UserModel { - const factory UserModel({ - required String id, - required String email, - String? name, - }) = _UserModel; +class SignalMessage with _$SignalMessage { + const SignalMessage._(); - factory UserModel.fromJson(Map json) => - _$UserModelFromJson(json); + const factory SignalMessage({ + String? type, + String? payload, + }) = _SignalMessage; + + factory SignalMessage.fromJson(Map json) => + _$SignalMessageFromJson(json); + + @override + String toString() => jsonEncode(toJson()); } ``` ### UI 组件 - 尽可能使用 `const` 构造函数,避免不必要的重建。 -- 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法(如 `_buildHeader()`)。 +- 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法。 - 响应式适配使用 `LayoutBuilder` / `MediaQuery` 或项目统一的屏幕适配工具。 --- @@ -135,11 +116,11 @@ class UserModel with _$UserModel { | 类别 | 规范 | 示例 | |---|---|---| -| 文件/文件夹 | `snake_case.dart` | `user_profile.dart` | -| 类/枚举 | `PascalCase` | `UserProfilePage` | -| 变量/方法 | `camelCase` | `getUserData()` | -| Provider | 以 `Provider` 结尾 | `userRepositoryProvider` | -| 私有成员 | `_` 前缀 | `_handleTap()` | +| 文件/文件夹 | `snake_case.dart` | `connection_controller.dart` | +| 类/枚举 | `PascalCase` | `ConnectionSessionState` | +| 变量/方法 | `camelCase` | `sendResolutionChange()` | +| Provider | 以 `Provider` 结尾 | `authRepositoryProvider` | +| 私有成员 | `_` 前缀 | `_handleSignalMessage()` | --- @@ -148,18 +129,22 @@ class UserModel with _$UserModel { 修改或新增带代码生成的模型/控制器后,运行: ```bash -# 一次性生成 -flutter pub run build_runner build --delete-conflicting-outputs +# 一次性生成(freezed / json_serializable / riverpod_generator) +dart run build_runner build --delete-conflicting-outputs # 监听模式 -flutter pub run build_runner watch --delete-conflicting-outputs +dart run build_runner watch --delete-conflicting-outputs + +# 生成国际化(l10n.yaml 已配置) +flutter gen-l10n ``` --- ## 7. 完成标准 -- 新文件遵循特性优先结构。 +- 新文件遵循特性优先结构(app / core / features / l10n)。 - 代码为空安全、完全类型化,并进行 `const` 优化。 - 对应创建数据层、领域层和表示层组件。 -- 不存在原始 `setState`(状态统一由 Riverpod 管理)。 +- 提交前 `flutter analyze` 无 issue;`flutter test` 通过。 +- 协议(`.proto` / 信令 JSON)变更需同步 Android / iOS / Web 各端。 diff --git a/webrtc_controller_flutter/README.md b/webrtc_controller_flutter/README.md index e41bbe2..114b305 100644 --- a/webrtc_controller_flutter/README.md +++ b/webrtc_controller_flutter/README.md @@ -9,8 +9,23 @@ - 通过 WebSocket 连接信令服务器并注册为 `CONTROLLER` 设备 - 创建 WebRTC 连接(仅接收远端视频 `recvonly` + 一条控制用 DataChannel) - 显示被控端画面(远端视频流) -- 通过触摸 / 滑动 / 物理按键采集输入,转换为相对坐标(0.0~1.0)经 DataChannel 发送控制指令(TOUCH / SWIPE / KEY) +- 通过触摸 / 滑动 / 物理按键采集输入,转换为相对坐标(0.0~1.0)经 DataChannel 发送控制指令(TOUCH / SWIPE / KEY / MOTION_EVENT) - 实时显示连接统计(分辨率、帧率、延迟、解码格式) +- 支持自编码(自建 MediaCodec 解码)与 WebRTC 全托管两种串流模式 +- 支持远程视频录制(MP4 保存到 app 专属目录) + +## 架构 + +采用 **特性优先(Feature-First)** + **整洁架构(Clean Architecture)** 分层: + +| 层 | 目录 | 职责 | +| --- | --- | --- | +| 全局 | `lib/app/` | 入口、路由(GoRouter)、主题、常量 | +| 共享 | `lib/core/` | 网络(dio)、Protobuf 协议、存储、工具、共享组件 | +| 认证 | `lib/features/auth/` | 登录 / 令牌 / 绑定列表(data / domain / presentation) | +| 连接 | `lib/features/connection/` | 信令、WebRTC、控制面板(data / domain / presentation) | + +状态管理使用 **Riverpod**(`@riverpod` 注解 + 代码生成),路由使用 **go_router**,网络使用 **dio**,数据模型使用 **freezed**。 ## 运行依赖(跨平台) @@ -18,22 +33,41 @@ | --- | --- | | `flutter_webrtc` | WebRTC(Android + iOS 统一封装) | | `web_socket_channel` | 信令 WebSocket | -| `uuid` | 生成本机设备 ID | - -> 以上包均支持 Android 与 iOS,无需编写任何平台原生代码。 +| `dio` | HTTP API(登录 / 刷新 / 绑定列表 / TURN 凭证) | +| `flutter_riverpod` | 状态管理 | +| `go_router` | 路由 | +| `freezed` + `json_serializable` | 不可变数据模型与 JSON 序列化 | +| `flutter_secure_storage` | 令牌安全存储(Keychain / EncryptedSharedPreferences) | ## 目录结构 ``` lib/ -├── config/ice_servers.dart # ICE/TURN/STUN 配置与默认信令地址 -├── models/signal_message.dart # 信令消息模型 -├── signaling/signaling_client.dart# WebSocket 信令客户端 -├── webrtc/webrtc_controller.dart # WebRTC 连接 / DataChannel / 视频渲染 / 统计 -├── controller/remote_controller.dart # 编排:信令 + WebRTC 流程 -├── utils/control_commands.dart # 控制指令 JSON 构造 -├── widgets/remote_touch_view.dart # 触摸/按键采集(纯 Flutter,跨平台) -└── main.dart # UI(设置面板 + 控制面板) +├── main.dart # 入口(ProviderScope + runApp) +├── app/ +│ ├── app.dart # CupertinoApp.router +│ ├── router/app_router.dart # GoRouter(/ = 设置页,/control = 控制页) +│ ├── theme/app_theme.dart # 全局主题 +│ └── constants/ # 全局常量、ICE 配置 +├── core/ +│ ├── network/ # dio、ApiException +│ ├── proto/ # Protobuf 生成代码 +│ ├── storage/ # 令牌安全存储 +│ ├── utils/ # 控制指令、设备工具 +│ └── widgets/ # RemoteTouchView 触摸层 +├── features/ +│ ├── auth/ # 登录 / 令牌 +│ └── connection/ # 信令 / WebRTC / 控制面板 +└── l10n/ # 国际化(.arb) +``` + +## 代码生成 + +新增或修改 freezed 模型 / Riverpod 控制器后: + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter gen-l10n ``` ## 运行 @@ -63,4 +97,4 @@ flutter run -d ios # 需 macOS + Xcode (REGISTER / OFFER / ANSWER / ICE_CANDIDATE,payload 为 JSON 字符串)。 - **被控端**:使用 WebRTCControlled(Android)接收 OFFER 并回传 ANSWER, 其 `InputCommandHandler` 解析 TOUCH / SWIPE / KEY 指令。 -- **ICE 服务器**:见 `lib/config/ice_servers.dart`,请按需替换为自己的 TURN 凭据。 +- **ICE 服务器**:见 `lib/app/constants/ice_servers.dart`,请按需替换为自己的 TURN 凭据。 diff --git a/webrtc_controller_flutter/l10n.yaml b/webrtc_controller_flutter/l10n.yaml new file mode 100644 index 0000000..7c401d5 --- /dev/null +++ b/webrtc_controller_flutter/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false diff --git a/webrtc_controller_flutter/lib/api/api_client.dart b/webrtc_controller_flutter/lib/api/api_client.dart deleted file mode 100644 index e8cd43f..0000000 --- a/webrtc_controller_flutter/lib/api/api_client.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; - -/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。 -const String kApiBase = String.fromEnvironment( - 'API_BASE', - defaultValue: 'https://www.ttstd.com', -); - -/// 账号体系与自助接口封装(对应服务端 /api/auth/* 与 /api/client/*)。 -/// -/// 令牌存储策略: -/// - accessToken:内存保存(掉线即失,需重新登录); -/// - refreshToken:flutter_secure_storage(Keychain / EncryptedSharedPreferences,一次性,ses_ 前缀)。 -class ApiClient { - ApiClient({http.Client? client}) : _http = client ?? http.Client(); - - final http.Client _http; - static const _storage = FlutterSecureStorage(); - - static const _kAccess = 'ttstd.accessToken'; - static const _kRefresh = 'ttstd.refreshToken'; - static const _kUsername = 'ttstd.username'; - - // 刷新单飞:避免并发触发多次刷新。 - Future>? _refreshing; - - String? _accessToken; - String? get accessToken => _accessToken; - - /// 从安全存储恢复令牌(应用启动时调用)。 - Future restore() async { - _accessToken = await _storage.read(key: _kAccess); - } - - bool get hasTokens { - // 同时读取内存 accessToken 与持久 refreshToken 判断。 - return _accessToken != null; - } - - Future hasRefreshToken() async => - (await _storage.read(key: _kRefresh)) != null; - - Future saveTokens({ - required String accessToken, - String? refreshToken, - String? username, - }) async { - _accessToken = accessToken; - await _storage.write(key: _kAccess, value: accessToken); - if (refreshToken != null) { - await _storage.write(key: _kRefresh, value: refreshToken); - } - if (username != null) { - await _storage.write(key: _kUsername, value: username); - } - } - - Future getUsername() => _storage.read(key: _kUsername); - - Future clear() async { - _accessToken = null; - await _storage.delete(key: _kAccess); - await _storage.delete(key: _kRefresh); - await _storage.delete(key: _kUsername); - } - - Future> login(String username, String password) async { - final data = await _post('/api/auth/login', - body: {'username': username, 'password': password}); - await saveTokens( - accessToken: data['accessToken'] as String, - refreshToken: data['refreshToken'] as String?, - username: username, - ); - return data; - } - - Future> register(String username, String password) => - _post('/api/auth/register', - body: {'username': username, 'password': password}); - - /// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。 - Future> refresh() async { - final rt = await _storage.read(key: _kRefresh); - if (rt == null) { - await clear(); - throw ApiException('NO_REFRESH_TOKEN', 401); - } - _refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null); - return _refreshing!; - } - - Future> _doRefresh(String rt) async { - final data = await _post('/api/auth/refresh', body: {'refreshToken': rt}); - await saveTokens( - accessToken: data['accessToken'] as String, - refreshToken: data['refreshToken'] as String?, - ); - return data; - } - - Future> verify() => _get('/api/client/verify'); - - Future> bindings() => _get('/api/client/bindings'); - - /// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回空 Map。 - Future?> turnCredentials() async { - try { - return await _get('/api/client/turn-credentials'); - } on ApiException { - return null; - } - } - - Future> _post(String path, {required Map body}) async { - final res = await _http.post( - Uri.parse('$kApiBase$path'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(body), - ); - return _handle(res); - } - - Future> _get(String path) async { - final res = await _http.get( - Uri.parse('$kApiBase$path'), - headers: { - 'Content-Type': 'application/json', - if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', - }, - ); - return _handle(res); - } - - Map _handle(http.Response res) { - final body = res.body.isNotEmpty ? jsonDecode(res.body) : {}; - if (res.statusCode == 401) { - throw ApiException(body['code'] ?? 'UNAUTHORIZED', 401); - } - if (res.statusCode < 200 || res.statusCode >= 300) { - throw ApiException(body['error'] ?? 'HTTP ${res.statusCode}', res.statusCode); - } - return body as Map; - } -} - -/// API 调用异常,携带服务端 code 与 HTTP 状态码。 -class ApiException implements Exception { - final String code; - final int httpCode; - ApiException(this.code, this.httpCode); - - @override - String toString() => code; -} diff --git a/webrtc_controller_flutter/lib/app/app.dart b/webrtc_controller_flutter/lib/app/app.dart new file mode 100644 index 0000000..c909924 --- /dev/null +++ b/webrtc_controller_flutter/lib/app/app.dart @@ -0,0 +1,30 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:webrtc_controller_flutter/l10n/app_localizations.dart'; + +import 'router/app_router.dart'; +import 'theme/app_theme.dart'; + +/// 应用根组件:CupertinoApp + GoRouter。 +class WebrtcControllerApp extends StatelessWidget { + const WebrtcControllerApp({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoApp.router( + title: 'WebRTC 控制端', + theme: AppTheme.light, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh'), + Locale('en'), + ], + routerConfig: appRouter, + ); + } +} diff --git a/webrtc_controller_flutter/lib/app/constants/app_constants.dart b/webrtc_controller_flutter/lib/app/constants/app_constants.dart new file mode 100644 index 0000000..abc0c9e --- /dev/null +++ b/webrtc_controller_flutter/lib/app/constants/app_constants.dart @@ -0,0 +1,24 @@ +// 全局常量与 API 端点。 + +/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。 +const String kApiBase = String.fromEnvironment( + 'API_BASE', + defaultValue: 'https://www.ttstd.com', +); + +/// 信令 WebSocket 默认地址。 +const String kDefaultSignalServer = 'wss://www.ttstd.com/signal'; + +/// DataChannel 标签,控制端与被控端需一致。 +const String kDataChannelLabel = 'control_channel'; + +/// 分辨率预设:width 为长边像素;0 表示被控端原生分辨率。 +const List> kResolutionOptions = [ + {'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 后以上报列表为准)。 +const List kDefaultFpsOptions = [15, 24, 30, 60]; diff --git a/webrtc_controller_flutter/lib/config/ice_servers.dart b/webrtc_controller_flutter/lib/app/constants/ice_servers.dart similarity index 82% rename from webrtc_controller_flutter/lib/config/ice_servers.dart rename to webrtc_controller_flutter/lib/app/constants/ice_servers.dart index 92c9f4c..77931aa 100644 --- a/webrtc_controller_flutter/lib/config/ice_servers.dart +++ b/webrtc_controller_flutter/lib/app/constants/ice_servers.dart @@ -6,10 +6,6 @@ /// 注意:旧配置曾误用 `175.178.213.60:3478`(错误凭据)及内网 /// `192.168.5.224:3478`(模拟器不可达),导致 iOS 模拟器下 /// `ICE Checking -> Failed`。已修正为与 iOS 原生项目相同的服务器。 -/// ICE 服务器配置。 -/// -/// 与「被控端 Android」同一台 TURN 服务器(175.178.213.60)、同一凭据 -/// (fanhuitong / Fan19961907..)。 /// /// **iOS 模拟器关键修复**: /// 明文 `turn:175.178.213.60:3478` 在 iOS 模拟器(libwebrtc)下不会发起 @@ -50,9 +46,3 @@ const List> kIceServers = [ 'credential': 'fht', }, ]; - -/// 信令 WebSocket 默认地址。 -const String kDefaultSignalServer = 'wss://www.ttstd.com/signal'; - -/// DataChannel 标签,控制端与被控端需一致。 -const String kDataChannelLabel = 'control_channel'; diff --git a/webrtc_controller_flutter/lib/app/router/app_router.dart b/webrtc_controller_flutter/lib/app/router/app_router.dart new file mode 100644 index 0000000..4395fd7 --- /dev/null +++ b/webrtc_controller_flutter/lib/app/router/app_router.dart @@ -0,0 +1,21 @@ +import 'package:go_router/go_router.dart'; + +import '../../features/connection/presentation/pages/control_page.dart'; +import '../../features/connection/presentation/pages/setup_page.dart'; + +/// 应用路由:/ = 连接设置页,/control = 控制面板页。 +final appRouter = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + name: 'setup', + builder: (context, state) => const SetupPage(), + ), + GoRoute( + path: '/control', + name: 'control', + builder: (context, state) => const ControlPage(), + ), + ], +); diff --git a/webrtc_controller_flutter/lib/app/theme/app_theme.dart b/webrtc_controller_flutter/lib/app/theme/app_theme.dart new file mode 100644 index 0000000..5eb8ebe --- /dev/null +++ b/webrtc_controller_flutter/lib/app/theme/app_theme.dart @@ -0,0 +1,11 @@ +import 'package:flutter/cupertino.dart'; + +/// 全局 Cupertino 主题配置。 +class AppTheme { + const AppTheme._(); + + /// 应用统一主题(iOS 风格)。 + static const CupertinoThemeData light = CupertinoThemeData( + primaryColor: CupertinoColors.activeBlue, + ); +} diff --git a/webrtc_controller_flutter/lib/core/network/api_exception.dart b/webrtc_controller_flutter/lib/core/network/api_exception.dart new file mode 100644 index 0000000..f8cf2fc --- /dev/null +++ b/webrtc_controller_flutter/lib/core/network/api_exception.dart @@ -0,0 +1,9 @@ +/// API 调用异常,携带服务端 code 与 HTTP 状态码。 +class ApiException implements Exception { + final String code; + final int httpCode; + const ApiException(this.code, this.httpCode); + + @override + String toString() => code; +} diff --git a/webrtc_controller_flutter/lib/proto/control_message.pb.dart b/webrtc_controller_flutter/lib/core/proto/control_message.pb.dart similarity index 100% rename from webrtc_controller_flutter/lib/proto/control_message.pb.dart rename to webrtc_controller_flutter/lib/core/proto/control_message.pb.dart diff --git a/webrtc_controller_flutter/lib/proto/control_message.pbenum.dart b/webrtc_controller_flutter/lib/core/proto/control_message.pbenum.dart similarity index 100% rename from webrtc_controller_flutter/lib/proto/control_message.pbenum.dart rename to webrtc_controller_flutter/lib/core/proto/control_message.pbenum.dart diff --git a/webrtc_controller_flutter/lib/proto/control_message.pbjson.dart b/webrtc_controller_flutter/lib/core/proto/control_message.pbjson.dart similarity index 100% rename from webrtc_controller_flutter/lib/proto/control_message.pbjson.dart rename to webrtc_controller_flutter/lib/core/proto/control_message.pbjson.dart diff --git a/webrtc_controller_flutter/lib/core/storage/token_storage.dart b/webrtc_controller_flutter/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..f014fbd --- /dev/null +++ b/webrtc_controller_flutter/lib/core/storage/token_storage.dart @@ -0,0 +1,33 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 令牌安全存储(Keychain / EncryptedSharedPreferences)。 +/// +/// 存储策略: +/// - accessToken:同时存内存与加密存储(掉线即失,需重新登录时可读取恢复); +/// - refreshToken:仅存加密存储(一次性,ses_ 前缀)。 +class TokenStorage { + const TokenStorage(); + + static const _storage = FlutterSecureStorage(); + + static const _kAccess = 'ttstd.accessToken'; + static const _kRefresh = 'ttstd.refreshToken'; + static const _kUsername = 'ttstd.username'; + + Future readAccess() => _storage.read(key: _kAccess); + Future readRefresh() => _storage.read(key: _kRefresh); + Future readUsername() => _storage.read(key: _kUsername); + + Future writeAccess(String token) => + _storage.write(key: _kAccess, value: token); + Future writeRefresh(String token) => + _storage.write(key: _kRefresh, value: token); + Future writeUsername(String username) => + _storage.write(key: _kUsername, value: username); + + Future clear() async { + await _storage.delete(key: _kAccess); + await _storage.delete(key: _kRefresh); + await _storage.delete(key: _kUsername); + } +} diff --git a/webrtc_controller_flutter/lib/utils/control_commands.dart b/webrtc_controller_flutter/lib/core/utils/control_commands.dart similarity index 94% rename from webrtc_controller_flutter/lib/utils/control_commands.dart rename to webrtc_controller_flutter/lib/core/utils/control_commands.dart index 97ec7c8..91ef1cd 100644 --- a/webrtc_controller_flutter/lib/utils/control_commands.dart +++ b/webrtc_controller_flutter/lib/core/utils/control_commands.dart @@ -1,5 +1,5 @@ import 'package:fixnum/fixnum.dart'; -import 'package:webrtc_controller_flutter/proto/control_message.pb.dart'; +import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart'; /// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。 /// @@ -47,7 +47,7 @@ class ControlCommands { y: y, ); - /// 原始 MotionEvent 指令,用于实现“实时跟手”。 + /// 原始 MotionEvent 指令,用于实现"实时跟手"。 /// action: 0=DOWN, 1=UP, 2=MOVE(对应 Android MotionEvent.ACTION_XXX)。 static ControlMessage motionEvent(int action, double x, double y) => ControlMessage( diff --git a/webrtc_controller_flutter/lib/utils/device_utils.dart b/webrtc_controller_flutter/lib/core/utils/device_utils.dart similarity index 100% rename from webrtc_controller_flutter/lib/utils/device_utils.dart rename to webrtc_controller_flutter/lib/core/utils/device_utils.dart diff --git a/webrtc_controller_flutter/lib/widgets/remote_touch_view.dart b/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart similarity index 99% rename from webrtc_controller_flutter/lib/widgets/remote_touch_view.dart rename to webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart index ab6bbe7..0fcdc98 100644 --- a/webrtc_controller_flutter/lib/widgets/remote_touch_view.dart +++ b/webrtc_controller_flutter/lib/core/widgets/remote_touch_view.dart @@ -55,7 +55,7 @@ class _RemoteTouchViewState extends State { // 采样率限制:控制 ACTION_MOVE 的发送频率(例如每 16ms 发送一次,约 60fps) int _lastMoveTimestamp = 0; - static const int _sampleIntervalMs = 16; + static const int _sampleIntervalMs = 16; /// 长按触发阈值,对应 Android GestureDetector 默认的 LONG_PRESS_TIMEOUT(400ms)。 static const _longPressTimeout = Duration(milliseconds: 400); @@ -124,7 +124,7 @@ class _RemoteTouchViewState extends State { }, onPointerMove: (event) { _currentPosition = event.localPosition; - + if (_cachedSize != null) { final now = DateTime.now().millisecondsSinceEpoch; // 采样率限制逻辑 @@ -180,5 +180,3 @@ class _RemoteTouchViewState extends State { ); } } - - diff --git a/webrtc_controller_flutter/lib/features/auth/data/auth_providers.dart b/webrtc_controller_flutter/lib/features/auth/data/auth_providers.dart new file mode 100644 index 0000000..df1c3a4 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/data/auth_providers.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../domain/auth_repository.dart'; +import 'dio_auth_repository.dart'; + +/// 认证仓库单例(登录 / 刷新 / 绑定列表 / TURN 凭证)。 +final authRepositoryProvider = Provider((ref) { + return DioAuthRepository(); +}); diff --git a/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart b/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart new file mode 100644 index 0000000..b2347bf --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/data/dio_auth_repository.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../../app/constants/app_constants.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/storage/token_storage.dart'; +import '../domain/auth_repository.dart'; + +/// 基于 Dio 的 [AuthRepository] 实现。 +class DioAuthRepository implements AuthRepository { + DioAuthRepository({Dio? dio, TokenStorage? storage}) + : _dio = dio ?? Dio(_baseOptions), + _storage = storage ?? const TokenStorage(); + + static final BaseOptions _baseOptions = BaseOptions( + baseUrl: kApiBase, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: {'Content-Type': 'application/json'}, + ); + + final Dio _dio; + final TokenStorage _storage; + + // 刷新单飞:避免并发触发多次刷新。 + Future>? _refreshing; + + String? _accessToken; + + @override + String? get accessToken => _accessToken; + + @override + Future restore() async { + _accessToken = await _storage.readAccess(); + } + + @override + Future hasRefreshToken() async => + await _storage.readRefresh() != null; + + @override + Future getUsername() => _storage.readUsername(); + + Future _saveTokens({ + required String accessToken, + String? refreshToken, + String? username, + }) async { + _accessToken = accessToken; + await _storage.writeAccess(accessToken); + if (refreshToken != null) { + await _storage.writeRefresh(refreshToken); + } + if (username != null) { + await _storage.writeUsername(username); + } + } + + @override + Future> login(String username, String password) async { + final data = await _post( + '/api/auth/login', + body: {'username': username, 'password': password}, + ); + await _saveTokens( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String?, + username: username, + ); + return data; + } + + @override + Future> register(String username, String password) => + _post('/api/auth/register', + body: {'username': username, 'password': password}); + + @override + Future> refresh() async { + final rt = await _storage.readRefresh(); + if (rt == null) { + await clear(); + throw const ApiException('NO_REFRESH_TOKEN', 401); + } + _refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null); + return _refreshing!; + } + + Future> _doRefresh(String rt) async { + final data = await _post('/api/auth/refresh', body: {'refreshToken': rt}); + await _saveTokens( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String?, + ); + return data; + } + + @override + Future> verify() => _get('/api/client/verify'); + + @override + Future> bindings() => _get('/api/client/bindings'); + + @override + Future?> turnCredentials() async { + try { + return await _get('/api/client/turn-credentials'); + } on ApiException { + return null; + } + } + + @override + Future clear() async { + _accessToken = null; + await _storage.clear(); + } + + Future> _post( + String path, { + required Map body, + }) async { + final res = await _dio.post( + path, + data: jsonEncode(body), + ); + return _handle(res); + } + + Future> _get(String path) async { + final res = await _dio.get( + path, + options: Options( + headers: { + if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', + }, + ), + ); + return _handle(res); + } + + Map _handle(Response res) { + final data = res.data; + final body = (data is Map) + ? data.cast() + : {}; + if (res.statusCode == 401) { + throw ApiException(body['code'] as String? ?? 'UNAUTHORIZED', 401); + } + if (res.statusCode == null || res.statusCode! < 200 || res.statusCode! >= 300) { + throw ApiException( + body['error'] as String? ?? 'HTTP ${res.statusCode}', + res.statusCode ?? -1, + ); + } + return body; + } +} diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart new file mode 100644 index 0000000..8cdef58 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_repository.dart @@ -0,0 +1,28 @@ +/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。 +abstract interface class AuthRepository { + /// 内存中的 accessToken(掉线即失)。 + String? get accessToken; + + /// 从安全存储恢复令牌(应用启动时调用)。 + Future restore(); + + Future hasRefreshToken(); + + Future getUsername(); + + Future> login(String username, String password); + + Future> register(String username, String password); + + /// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。 + Future> refresh(); + + Future> verify(); + + Future> bindings(); + + /// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。 + Future?> turnCredentials(); + + Future clear(); +} diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart new file mode 100644 index 0000000..8c69d44 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth_state.freezed.dart'; + +/// 登录状态。 +@freezed +class AuthState with _$AuthState { + const factory AuthState({ + @Default(false) bool loggedIn, + @Default(false) bool restoring, + String? username, + String? error, + }) = _AuthState; +} diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart new file mode 100644 index 0000000..84d1f30 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart @@ -0,0 +1,214 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$AuthState { + bool get loggedIn => throw _privateConstructorUsedError; + bool get restoring => throw _privateConstructorUsedError; + String? get username => throw _privateConstructorUsedError; + String? get error => throw _privateConstructorUsedError; + + /// Create a copy of AuthState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AuthStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AuthStateCopyWith<$Res> { + factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) then) = + _$AuthStateCopyWithImpl<$Res, AuthState>; + @useResult + $Res call({bool loggedIn, bool restoring, String? username, String? error}); +} + +/// @nodoc +class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState> + implements $AuthStateCopyWith<$Res> { + _$AuthStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AuthState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loggedIn = null, + Object? restoring = null, + Object? username = freezed, + Object? error = freezed, + }) { + return _then( + _value.copyWith( + loggedIn: null == loggedIn + ? _value.loggedIn + : loggedIn // ignore: cast_nullable_to_non_nullable + as bool, + restoring: null == restoring + ? _value.restoring + : restoring // ignore: cast_nullable_to_non_nullable + as bool, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + error: freezed == error + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AuthStateImplCopyWith<$Res> + implements $AuthStateCopyWith<$Res> { + factory _$$AuthStateImplCopyWith( + _$AuthStateImpl value, + $Res Function(_$AuthStateImpl) then, + ) = __$$AuthStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool loggedIn, bool restoring, String? username, String? error}); +} + +/// @nodoc +class __$$AuthStateImplCopyWithImpl<$Res> + extends _$AuthStateCopyWithImpl<$Res, _$AuthStateImpl> + implements _$$AuthStateImplCopyWith<$Res> { + __$$AuthStateImplCopyWithImpl( + _$AuthStateImpl _value, + $Res Function(_$AuthStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AuthState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loggedIn = null, + Object? restoring = null, + Object? username = freezed, + Object? error = freezed, + }) { + return _then( + _$AuthStateImpl( + loggedIn: null == loggedIn + ? _value.loggedIn + : loggedIn // ignore: cast_nullable_to_non_nullable + as bool, + restoring: null == restoring + ? _value.restoring + : restoring // ignore: cast_nullable_to_non_nullable + as bool, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + error: freezed == error + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$AuthStateImpl implements _AuthState { + const _$AuthStateImpl({ + this.loggedIn = false, + this.restoring = false, + this.username, + this.error, + }); + + @override + @JsonKey() + final bool loggedIn; + @override + @JsonKey() + final bool restoring; + @override + final String? username; + @override + final String? error; + + @override + String toString() { + return 'AuthState(loggedIn: $loggedIn, restoring: $restoring, username: $username, error: $error)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AuthStateImpl && + (identical(other.loggedIn, loggedIn) || + other.loggedIn == loggedIn) && + (identical(other.restoring, restoring) || + other.restoring == restoring) && + (identical(other.username, username) || + other.username == username) && + (identical(other.error, error) || other.error == error)); + } + + @override + int get hashCode => + Object.hash(runtimeType, loggedIn, restoring, username, error); + + /// Create a copy of AuthState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AuthStateImplCopyWith<_$AuthStateImpl> get copyWith => + __$$AuthStateImplCopyWithImpl<_$AuthStateImpl>(this, _$identity); +} + +abstract class _AuthState implements AuthState { + const factory _AuthState({ + final bool loggedIn, + final bool restoring, + final String? username, + final String? error, + }) = _$AuthStateImpl; + + @override + bool get loggedIn; + @override + bool get restoring; + @override + String? get username; + @override + String? get error; + + /// Create a copy of AuthState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AuthStateImplCopyWith<_$AuthStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart new file mode 100644 index 0000000..1a19f18 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart @@ -0,0 +1,56 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/auth_providers.dart'; +import '../domain/auth_repository.dart'; +import '../domain/auth_state.dart'; + +part 'auth_controller.g.dart'; + +/// 登录状态控制器。 +@Riverpod(keepAlive: true) +class AuthController extends _$AuthController { + AuthRepository get _repository => ref.read(authRepositoryProvider); + + @override + FutureOr build() async { + final repository = ref.watch(authRepositoryProvider); + await repository.restore(); + final loggedIn = + repository.accessToken != null && await repository.hasRefreshToken(); + return AuthState( + loggedIn: loggedIn, + restoring: false, + username: await repository.getUsername(), + ); + } + + /// 登录:成功返回 true,失败返回 false 并写入错误信息。 + Future login(String username, String password) async { + try { + await _repository.login(username, password); + state = AsyncData( + AuthState(loggedIn: true, username: username), + ); + return true; + } catch (e) { + state = AsyncData( + AuthState(loggedIn: false, error: e.toString()), + ); + return false; + } + } + + /// 退出登录:清空令牌并复位状态。 + Future logout() async { + await _repository.clear(); + state = const AsyncData(AuthState()); + } + + /// 清空错误信息。 + void clearError() { + final current = state.valueOrNull; + if (current != null && current.error != null) { + state = AsyncData(current.copyWith(error: null)); + } + } +} diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart new file mode 100644 index 0000000..bc0823a --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authControllerHash() => r'35d9a474535207949af85ca44019d70f50afd7c2'; + +/// 登录状态控制器。 +/// +/// Copied from [AuthController]. +@ProviderFor(AuthController) +final authControllerProvider = + AsyncNotifierProvider.internal( + AuthController.new, + name: r'authControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$authControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AuthController = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart b/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart new file mode 100644 index 0000000..b8593c8 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart @@ -0,0 +1,97 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:webrtc_controller_flutter/l10n/app_localizations.dart'; + +import '../auth_controller.dart'; + +/// 登录对话框:输入用户名/密码,调用登录保存令牌。 +class LoginDialog extends ConsumerStatefulWidget { + const LoginDialog({super.key}); + + @override + ConsumerState createState() => _LoginDialogState(); +} + +class _LoginDialogState extends ConsumerState { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return CupertinoAlertDialog( + title: Text(l10n.login), + content: Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + children: [ + CupertinoTextField( + controller: _usernameController, + placeholder: l10n.username, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + ), + const SizedBox(height: 10), + CupertinoTextField( + controller: _passwordController, + placeholder: l10n.password, + obscureText: true, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.of(context).pop(false), + ), + CupertinoDialogAction( + child: Text(l10n.login), + onPressed: () async { + final user = _usernameController.text.trim(); + final pass = _passwordController.text.trim(); + if (user.isEmpty || pass.isEmpty) { + _showAlert(context, l10n.usernamePasswordRequired); + return; + } + final ok = await ref + .read(authControllerProvider.notifier) + .login(user, pass); + if (!context.mounted) return; + if (ok) { + Navigator.of(context).pop(true); + } else { + final error = ref.read(authControllerProvider).valueOrNull?.error; + _showAlert( + context, + l10n.loginFailed(error ?? ''), + ); + } + }, + ), + ], + ); + } + + static void _showAlert(BuildContext context, String message) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + content: Text(message), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/controller/remote_controller.dart b/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart similarity index 93% rename from webrtc_controller_flutter/lib/controller/remote_controller.dart rename to webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart index 47dbe91..1494950 100644 --- a/webrtc_controller_flutter/lib/controller/remote_controller.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/remote_controller.dart @@ -3,18 +3,19 @@ import 'dart:convert'; import 'package:flutter_webrtc/flutter_webrtc.dart'; -import '../api/api_client.dart'; -import '../models/signal_message.dart'; -import '../proto/control_message.pb.dart'; -import '../signaling/signaling_client.dart'; -import '../webrtc/webrtc_controller.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/proto/control_message.pb.dart'; +import '../../auth/domain/auth_repository.dart'; +import '../domain/signal_message.dart'; +import 'signaling_client.dart'; +import 'webrtc_controller.dart'; /// 控制端编排器:组合信令客户端与 WebRTC 控制器, /// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。 class RemoteController { final String serverUrl; final String targetDeviceId; - final ApiClient apiClient; + final AuthRepository authRepository; String? token; String? authType; @@ -82,7 +83,7 @@ class RemoteController { RemoteController({ required this.serverUrl, required this.targetDeviceId, - required this.apiClient, + required this.authRepository, this.token, this.authType, this.authValue, @@ -118,8 +119,8 @@ class RemoteController { }; _signaling.onTokenExpired = () async { try { - await apiClient.refresh(); - token = apiClient.accessToken; + await authRepository.refresh(); + token = authRepository.accessToken; _reconnect(); } catch (e) { onStatusChanged?.call('状态: 令牌刷新失败 - $e'); @@ -135,16 +136,16 @@ class RemoteController { /// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。 Future _ensureToken() async { - final existing = apiClient.accessToken; + final existing = authRepository.accessToken; if (existing != null) { try { - await apiClient.verify(); + await authRepository.verify(); return existing; } on ApiException catch (e) { if (e.httpCode != 401) return existing; } } - final data = await apiClient.refresh(); + final data = await authRepository.refresh(); return data['accessToken'] as String; } @@ -241,7 +242,7 @@ class RemoteController { /// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。 Future _loadBindings() async { try { - final data = await apiClient.bindings(); + final data = await authRepository.bindings(); final list = (data['bindings'] as List?) ?? []; if (list.isNotEmpty) { final uids = list.map((e) { @@ -261,7 +262,7 @@ class RemoteController { /// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。 Future _loadTurnCredentials() async { - final data = await apiClient.turnCredentials(); + final data = await authRepository.turnCredentials(); if (data != null && data['iceServers'] is List) { final servers = (data['iceServers'] as List) .map((e) => Map.from(e as Map)) diff --git a/webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart b/webrtc_controller_flutter/lib/features/connection/data/self_codec_decoder.dart similarity index 100% rename from webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart rename to webrtc_controller_flutter/lib/features/connection/data/self_codec_decoder.dart diff --git a/webrtc_controller_flutter/lib/signaling/signaling_client.dart b/webrtc_controller_flutter/lib/features/connection/data/signaling_client.dart similarity index 91% rename from webrtc_controller_flutter/lib/signaling/signaling_client.dart rename to webrtc_controller_flutter/lib/features/connection/data/signaling_client.dart index 58a489a..7170cd4 100644 --- a/webrtc_controller_flutter/lib/signaling/signaling_client.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/signaling_client.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../models/signal_message.dart'; +import '../domain/signal_message.dart'; /// 信令客户端,封装 WebSocket 连接与消息收发。 /// @@ -69,11 +69,6 @@ class SignalingClient { try { final map = jsonDecode(data) as Map; final message = SignalMessage.fromJson(map); - // 记录服务端下发的本机 deviceId(REGISTER_SUCCESS.fromDeviceId)。 - if (message.type?.toUpperCase() == 'REGISTER_SUCCESS' && - message.fromDeviceId != null) { - // 透传给上层,由 RemoteController 处理。 - } onMessage?.call(message); } catch (_) { // 忽略无法解析的消息。 diff --git a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart b/webrtc_controller_flutter/lib/features/connection/data/video_recorder.dart similarity index 97% rename from webrtc_controller_flutter/lib/webrtc/video_recorder.dart rename to webrtc_controller_flutter/lib/features/connection/data/video_recorder.dart index dc21dcf..46df64a 100644 --- a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/video_recorder.dart @@ -48,7 +48,7 @@ class VideoRecorder { try { await _recorder!.stop(); } catch (e) { - // 停止失败时仍清理状态,避免界面卡在“录制中” + // 停止失败时仍清理状态,避免界面卡在"录制中" // ignore: avoid_print print('[VideoRecorder] stop error: $e'); } finally { diff --git a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart b/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart similarity index 98% rename from webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart rename to webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart index 3908ef1..c1add18 100644 --- a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/features/connection/data/webrtc_controller.dart @@ -2,13 +2,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import '../../../app/constants/app_constants.dart'; +import '../../../app/constants/ice_servers.dart'; +import '../../../core/proto/control_message.pb.dart'; +import '../../../core/utils/control_commands.dart'; +import '../domain/signal_message.dart'; import 'self_codec_decoder.dart'; - -import '../config/ice_servers.dart'; -import '../models/signal_message.dart'; -import '../proto/control_message.pb.dart'; -import '../signaling/signaling_client.dart'; -import '../utils/control_commands.dart'; +import 'signaling_client.dart'; /// WebRTC 全托管模式下,由原生 VideoSink 探针逐帧实测得到的解码耗时统计。 class _DecodeProbeStats { @@ -138,7 +138,7 @@ class WebRtcController { ); // 创建控制用 DataChannel。 - // 使用非可靠、无序模式以降低延迟,解决“不跟手”问题。 + // 使用非可靠、无序模式以降低延迟,解决"不跟手"问题。 final dcInit = RTCDataChannelInit() ..ordered = false ..maxRetransmits = 0; @@ -229,7 +229,7 @@ class WebRtcController { /// 与 Web 端 WebRtcController.ontrack 的兜底逻辑保持一致: /// 某些平台/协商场景下(Unified Plan + recvonly)`event.streams` 可能为空, /// 此时必须用 `event.track` 自行构造 MediaStream,否则 renderer.srcObject - /// 为空 -> 控制端拿不到画面(黑屏/一直显示“等待画面”),但控制通道不受影响。 + /// 为空 -> 控制端拿不到画面(黑屏/一直显示"等待画面"),但控制通道不受影响。 Future _bindRemoteVideo(RTCTrackEvent event) async { MediaStream stream; if (event.streams.isNotEmpty) { diff --git a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart new file mode 100644 index 0000000..cba3ef4 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.dart @@ -0,0 +1,72 @@ +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../app/constants/app_constants.dart'; + +part 'connection_session_state.freezed.dart'; + +/// 连接控制会话的 UI 状态。 +@freezed +class ConnectionSessionState with _$ConnectionSessionState { + const factory ConnectionSessionState({ + /// 是否已建立 WebRTC 连接(可开始远程控制)。 + @Default(false) bool connected, + + /// 是否正在连接信令服务器 / 建立 WebRTC。 + @Default(false) bool connecting, + + /// 状态提示文本。 + @Default('') String status, + + /// 连接统计文本(每秒刷新)。 + @Default('') String stats, + + /// 当前视频宽高比。 + @Default(16 / 9) double videoAspect, + + /// 当前串流模式:0=WebRTC 全托管;1=自编码。 + @Default(0) int streamMode, + + /// 自编码解码纹理 id。 + int? selfCodecTextureId, + + /// 自编码解码器是否就绪。 + @Default(false) bool selfCodecReady, + + /// 当前平台是否支持自编码硬解。 + @Default(true) bool selfCodecSupported, + + /// 是否正在录制远程视频。 + @Default(false) bool recording, + + /// 录制状态提示。 + @Default('') String recordStatus, + + /// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。 + @Default(false) bool pendingRecordStart, + + /// 当前选中的分辨率预设下标。 + @Default(0) int selectedResolution, + + /// 分辨率预设列表。 + @Default(kResolutionOptions) List> resolutionOptions, + + /// 帧率档位(收到被控端上报后以上报列表为准)。 + @Default(kDefaultFpsOptions) List fpsOptions, + + /// 被控端当前采集帧率(0 表示尚未收到上报)。 + @Default(0) int currentFps, + + /// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。 + @Default(0) int lastReportedWidth, + + /// 被控端最近上报的实际采集尺寸。 + @Default(0) int lastReportedHeight, + + /// 远端视频渲染器(未连接为 null)。 + RTCVideoRenderer? renderer, + + /// 一次性提示消息(消费后由控制器置空)。 + String? alert, + }) = _ConnectionSessionState; +} diff --git a/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart new file mode 100644 index 0000000..aa76641 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/connection_session_state.freezed.dart @@ -0,0 +1,742 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connection_session_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$ConnectionSessionState { + /// 是否已建立 WebRTC 连接(可开始远程控制)。 + bool get connected => throw _privateConstructorUsedError; + + /// 是否正在连接信令服务器 / 建立 WebRTC。 + bool get connecting => throw _privateConstructorUsedError; + + /// 状态提示文本。 + String get status => throw _privateConstructorUsedError; + + /// 连接统计文本(每秒刷新)。 + String get stats => throw _privateConstructorUsedError; + + /// 当前视频宽高比。 + double get videoAspect => throw _privateConstructorUsedError; + + /// 当前串流模式:0=WebRTC 全托管;1=自编码。 + int get streamMode => throw _privateConstructorUsedError; + + /// 自编码解码纹理 id。 + int? get selfCodecTextureId => throw _privateConstructorUsedError; + + /// 自编码解码器是否就绪。 + bool get selfCodecReady => throw _privateConstructorUsedError; + + /// 当前平台是否支持自编码硬解。 + bool get selfCodecSupported => throw _privateConstructorUsedError; + + /// 是否正在录制远程视频。 + bool get recording => throw _privateConstructorUsedError; + + /// 录制状态提示。 + String get recordStatus => throw _privateConstructorUsedError; + + /// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。 + bool get pendingRecordStart => throw _privateConstructorUsedError; + + /// 当前选中的分辨率预设下标。 + int get selectedResolution => throw _privateConstructorUsedError; + + /// 分辨率预设列表。 + List> get resolutionOptions => + throw _privateConstructorUsedError; + + /// 帧率档位(收到被控端上报后以上报列表为准)。 + List get fpsOptions => throw _privateConstructorUsedError; + + /// 被控端当前采集帧率(0 表示尚未收到上报)。 + int get currentFps => throw _privateConstructorUsedError; + + /// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。 + int get lastReportedWidth => throw _privateConstructorUsedError; + + /// 被控端最近上报的实际采集尺寸。 + int get lastReportedHeight => throw _privateConstructorUsedError; + + /// 远端视频渲染器(未连接为 null)。 + RTCVideoRenderer? get renderer => throw _privateConstructorUsedError; + + /// 一次性提示消息(消费后由控制器置空)。 + String? get alert => throw _privateConstructorUsedError; + + /// Create a copy of ConnectionSessionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ConnectionSessionStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ConnectionSessionStateCopyWith<$Res> { + factory $ConnectionSessionStateCopyWith( + ConnectionSessionState value, + $Res Function(ConnectionSessionState) then, + ) = _$ConnectionSessionStateCopyWithImpl<$Res, ConnectionSessionState>; + @useResult + $Res call({ + bool connected, + bool connecting, + String status, + String stats, + double videoAspect, + int streamMode, + int? selfCodecTextureId, + bool selfCodecReady, + bool selfCodecSupported, + bool recording, + String recordStatus, + bool pendingRecordStart, + int selectedResolution, + List> resolutionOptions, + List fpsOptions, + int currentFps, + int lastReportedWidth, + int lastReportedHeight, + RTCVideoRenderer? renderer, + String? alert, + }); +} + +/// @nodoc +class _$ConnectionSessionStateCopyWithImpl< + $Res, + $Val extends ConnectionSessionState +> + implements $ConnectionSessionStateCopyWith<$Res> { + _$ConnectionSessionStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ConnectionSessionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? connected = null, + Object? connecting = null, + Object? status = null, + Object? stats = null, + Object? videoAspect = null, + Object? streamMode = null, + Object? selfCodecTextureId = freezed, + Object? selfCodecReady = null, + Object? selfCodecSupported = null, + Object? recording = null, + Object? recordStatus = null, + Object? pendingRecordStart = null, + Object? selectedResolution = null, + Object? resolutionOptions = null, + Object? fpsOptions = null, + Object? currentFps = null, + Object? lastReportedWidth = null, + Object? lastReportedHeight = null, + Object? renderer = freezed, + Object? alert = freezed, + }) { + return _then( + _value.copyWith( + connected: null == connected + ? _value.connected + : connected // ignore: cast_nullable_to_non_nullable + as bool, + connecting: null == connecting + ? _value.connecting + : connecting // ignore: cast_nullable_to_non_nullable + as bool, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + stats: null == stats + ? _value.stats + : stats // ignore: cast_nullable_to_non_nullable + as String, + videoAspect: null == videoAspect + ? _value.videoAspect + : videoAspect // ignore: cast_nullable_to_non_nullable + as double, + streamMode: null == streamMode + ? _value.streamMode + : streamMode // ignore: cast_nullable_to_non_nullable + as int, + selfCodecTextureId: freezed == selfCodecTextureId + ? _value.selfCodecTextureId + : selfCodecTextureId // ignore: cast_nullable_to_non_nullable + as int?, + selfCodecReady: null == selfCodecReady + ? _value.selfCodecReady + : selfCodecReady // ignore: cast_nullable_to_non_nullable + as bool, + selfCodecSupported: null == selfCodecSupported + ? _value.selfCodecSupported + : selfCodecSupported // ignore: cast_nullable_to_non_nullable + as bool, + recording: null == recording + ? _value.recording + : recording // ignore: cast_nullable_to_non_nullable + as bool, + recordStatus: null == recordStatus + ? _value.recordStatus + : recordStatus // ignore: cast_nullable_to_non_nullable + as String, + pendingRecordStart: null == pendingRecordStart + ? _value.pendingRecordStart + : pendingRecordStart // ignore: cast_nullable_to_non_nullable + as bool, + selectedResolution: null == selectedResolution + ? _value.selectedResolution + : selectedResolution // ignore: cast_nullable_to_non_nullable + as int, + resolutionOptions: null == resolutionOptions + ? _value.resolutionOptions + : resolutionOptions // ignore: cast_nullable_to_non_nullable + as List>, + fpsOptions: null == fpsOptions + ? _value.fpsOptions + : fpsOptions // ignore: cast_nullable_to_non_nullable + as List, + currentFps: null == currentFps + ? _value.currentFps + : currentFps // ignore: cast_nullable_to_non_nullable + as int, + lastReportedWidth: null == lastReportedWidth + ? _value.lastReportedWidth + : lastReportedWidth // ignore: cast_nullable_to_non_nullable + as int, + lastReportedHeight: null == lastReportedHeight + ? _value.lastReportedHeight + : lastReportedHeight // ignore: cast_nullable_to_non_nullable + as int, + renderer: freezed == renderer + ? _value.renderer + : renderer // ignore: cast_nullable_to_non_nullable + as RTCVideoRenderer?, + alert: freezed == alert + ? _value.alert + : alert // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ConnectionSessionStateImplCopyWith<$Res> + implements $ConnectionSessionStateCopyWith<$Res> { + factory _$$ConnectionSessionStateImplCopyWith( + _$ConnectionSessionStateImpl value, + $Res Function(_$ConnectionSessionStateImpl) then, + ) = __$$ConnectionSessionStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + bool connected, + bool connecting, + String status, + String stats, + double videoAspect, + int streamMode, + int? selfCodecTextureId, + bool selfCodecReady, + bool selfCodecSupported, + bool recording, + String recordStatus, + bool pendingRecordStart, + int selectedResolution, + List> resolutionOptions, + List fpsOptions, + int currentFps, + int lastReportedWidth, + int lastReportedHeight, + RTCVideoRenderer? renderer, + String? alert, + }); +} + +/// @nodoc +class __$$ConnectionSessionStateImplCopyWithImpl<$Res> + extends + _$ConnectionSessionStateCopyWithImpl<$Res, _$ConnectionSessionStateImpl> + implements _$$ConnectionSessionStateImplCopyWith<$Res> { + __$$ConnectionSessionStateImplCopyWithImpl( + _$ConnectionSessionStateImpl _value, + $Res Function(_$ConnectionSessionStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ConnectionSessionState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? connected = null, + Object? connecting = null, + Object? status = null, + Object? stats = null, + Object? videoAspect = null, + Object? streamMode = null, + Object? selfCodecTextureId = freezed, + Object? selfCodecReady = null, + Object? selfCodecSupported = null, + Object? recording = null, + Object? recordStatus = null, + Object? pendingRecordStart = null, + Object? selectedResolution = null, + Object? resolutionOptions = null, + Object? fpsOptions = null, + Object? currentFps = null, + Object? lastReportedWidth = null, + Object? lastReportedHeight = null, + Object? renderer = freezed, + Object? alert = freezed, + }) { + return _then( + _$ConnectionSessionStateImpl( + connected: null == connected + ? _value.connected + : connected // ignore: cast_nullable_to_non_nullable + as bool, + connecting: null == connecting + ? _value.connecting + : connecting // ignore: cast_nullable_to_non_nullable + as bool, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + stats: null == stats + ? _value.stats + : stats // ignore: cast_nullable_to_non_nullable + as String, + videoAspect: null == videoAspect + ? _value.videoAspect + : videoAspect // ignore: cast_nullable_to_non_nullable + as double, + streamMode: null == streamMode + ? _value.streamMode + : streamMode // ignore: cast_nullable_to_non_nullable + as int, + selfCodecTextureId: freezed == selfCodecTextureId + ? _value.selfCodecTextureId + : selfCodecTextureId // ignore: cast_nullable_to_non_nullable + as int?, + selfCodecReady: null == selfCodecReady + ? _value.selfCodecReady + : selfCodecReady // ignore: cast_nullable_to_non_nullable + as bool, + selfCodecSupported: null == selfCodecSupported + ? _value.selfCodecSupported + : selfCodecSupported // ignore: cast_nullable_to_non_nullable + as bool, + recording: null == recording + ? _value.recording + : recording // ignore: cast_nullable_to_non_nullable + as bool, + recordStatus: null == recordStatus + ? _value.recordStatus + : recordStatus // ignore: cast_nullable_to_non_nullable + as String, + pendingRecordStart: null == pendingRecordStart + ? _value.pendingRecordStart + : pendingRecordStart // ignore: cast_nullable_to_non_nullable + as bool, + selectedResolution: null == selectedResolution + ? _value.selectedResolution + : selectedResolution // ignore: cast_nullable_to_non_nullable + as int, + resolutionOptions: null == resolutionOptions + ? _value._resolutionOptions + : resolutionOptions // ignore: cast_nullable_to_non_nullable + as List>, + fpsOptions: null == fpsOptions + ? _value._fpsOptions + : fpsOptions // ignore: cast_nullable_to_non_nullable + as List, + currentFps: null == currentFps + ? _value.currentFps + : currentFps // ignore: cast_nullable_to_non_nullable + as int, + lastReportedWidth: null == lastReportedWidth + ? _value.lastReportedWidth + : lastReportedWidth // ignore: cast_nullable_to_non_nullable + as int, + lastReportedHeight: null == lastReportedHeight + ? _value.lastReportedHeight + : lastReportedHeight // ignore: cast_nullable_to_non_nullable + as int, + renderer: freezed == renderer + ? _value.renderer + : renderer // ignore: cast_nullable_to_non_nullable + as RTCVideoRenderer?, + alert: freezed == alert + ? _value.alert + : alert // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$ConnectionSessionStateImpl implements _ConnectionSessionState { + const _$ConnectionSessionStateImpl({ + this.connected = false, + this.connecting = false, + this.status = '', + this.stats = '', + this.videoAspect = 16 / 9, + this.streamMode = 0, + this.selfCodecTextureId, + this.selfCodecReady = false, + this.selfCodecSupported = true, + this.recording = false, + this.recordStatus = '', + this.pendingRecordStart = false, + this.selectedResolution = 0, + final List> resolutionOptions = kResolutionOptions, + final List fpsOptions = kDefaultFpsOptions, + this.currentFps = 0, + this.lastReportedWidth = 0, + this.lastReportedHeight = 0, + this.renderer, + this.alert, + }) : _resolutionOptions = resolutionOptions, + _fpsOptions = fpsOptions; + + /// 是否已建立 WebRTC 连接(可开始远程控制)。 + @override + @JsonKey() + final bool connected; + + /// 是否正在连接信令服务器 / 建立 WebRTC。 + @override + @JsonKey() + final bool connecting; + + /// 状态提示文本。 + @override + @JsonKey() + final String status; + + /// 连接统计文本(每秒刷新)。 + @override + @JsonKey() + final String stats; + + /// 当前视频宽高比。 + @override + @JsonKey() + final double videoAspect; + + /// 当前串流模式:0=WebRTC 全托管;1=自编码。 + @override + @JsonKey() + final int streamMode; + + /// 自编码解码纹理 id。 + @override + final int? selfCodecTextureId; + + /// 自编码解码器是否就绪。 + @override + @JsonKey() + final bool selfCodecReady; + + /// 当前平台是否支持自编码硬解。 + @override + @JsonKey() + final bool selfCodecSupported; + + /// 是否正在录制远程视频。 + @override + @JsonKey() + final bool recording; + + /// 录制状态提示。 + @override + @JsonKey() + final String recordStatus; + + /// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。 + @override + @JsonKey() + final bool pendingRecordStart; + + /// 当前选中的分辨率预设下标。 + @override + @JsonKey() + final int selectedResolution; + + /// 分辨率预设列表。 + final List> _resolutionOptions; + + /// 分辨率预设列表。 + @override + @JsonKey() + List> get resolutionOptions { + if (_resolutionOptions is EqualUnmodifiableListView) + return _resolutionOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_resolutionOptions); + } + + /// 帧率档位(收到被控端上报后以上报列表为准)。 + final List _fpsOptions; + + /// 帧率档位(收到被控端上报后以上报列表为准)。 + @override + @JsonKey() + List get fpsOptions { + if (_fpsOptions is EqualUnmodifiableListView) return _fpsOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_fpsOptions); + } + + /// 被控端当前采集帧率(0 表示尚未收到上报)。 + @override + @JsonKey() + final int currentFps; + + /// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。 + @override + @JsonKey() + final int lastReportedWidth; + + /// 被控端最近上报的实际采集尺寸。 + @override + @JsonKey() + final int lastReportedHeight; + + /// 远端视频渲染器(未连接为 null)。 + @override + final RTCVideoRenderer? renderer; + + /// 一次性提示消息(消费后由控制器置空)。 + @override + final String? alert; + + @override + String toString() { + return 'ConnectionSessionState(connected: $connected, connecting: $connecting, status: $status, stats: $stats, videoAspect: $videoAspect, streamMode: $streamMode, selfCodecTextureId: $selfCodecTextureId, selfCodecReady: $selfCodecReady, selfCodecSupported: $selfCodecSupported, recording: $recording, recordStatus: $recordStatus, pendingRecordStart: $pendingRecordStart, selectedResolution: $selectedResolution, resolutionOptions: $resolutionOptions, fpsOptions: $fpsOptions, currentFps: $currentFps, lastReportedWidth: $lastReportedWidth, lastReportedHeight: $lastReportedHeight, renderer: $renderer, alert: $alert)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ConnectionSessionStateImpl && + (identical(other.connected, connected) || + other.connected == connected) && + (identical(other.connecting, connecting) || + other.connecting == connecting) && + (identical(other.status, status) || other.status == status) && + (identical(other.stats, stats) || other.stats == stats) && + (identical(other.videoAspect, videoAspect) || + other.videoAspect == videoAspect) && + (identical(other.streamMode, streamMode) || + other.streamMode == streamMode) && + (identical(other.selfCodecTextureId, selfCodecTextureId) || + other.selfCodecTextureId == selfCodecTextureId) && + (identical(other.selfCodecReady, selfCodecReady) || + other.selfCodecReady == selfCodecReady) && + (identical(other.selfCodecSupported, selfCodecSupported) || + other.selfCodecSupported == selfCodecSupported) && + (identical(other.recording, recording) || + other.recording == recording) && + (identical(other.recordStatus, recordStatus) || + other.recordStatus == recordStatus) && + (identical(other.pendingRecordStart, pendingRecordStart) || + other.pendingRecordStart == pendingRecordStart) && + (identical(other.selectedResolution, selectedResolution) || + other.selectedResolution == selectedResolution) && + const DeepCollectionEquality().equals( + other._resolutionOptions, + _resolutionOptions, + ) && + const DeepCollectionEquality().equals( + other._fpsOptions, + _fpsOptions, + ) && + (identical(other.currentFps, currentFps) || + other.currentFps == currentFps) && + (identical(other.lastReportedWidth, lastReportedWidth) || + other.lastReportedWidth == lastReportedWidth) && + (identical(other.lastReportedHeight, lastReportedHeight) || + other.lastReportedHeight == lastReportedHeight) && + (identical(other.renderer, renderer) || + other.renderer == renderer) && + (identical(other.alert, alert) || other.alert == alert)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + connected, + connecting, + status, + stats, + videoAspect, + streamMode, + selfCodecTextureId, + selfCodecReady, + selfCodecSupported, + recording, + recordStatus, + pendingRecordStart, + selectedResolution, + const DeepCollectionEquality().hash(_resolutionOptions), + const DeepCollectionEquality().hash(_fpsOptions), + currentFps, + lastReportedWidth, + lastReportedHeight, + renderer, + alert, + ]); + + /// Create a copy of ConnectionSessionState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl> + get copyWith => + __$$ConnectionSessionStateImplCopyWithImpl<_$ConnectionSessionStateImpl>( + this, + _$identity, + ); +} + +abstract class _ConnectionSessionState implements ConnectionSessionState { + const factory _ConnectionSessionState({ + final bool connected, + final bool connecting, + final String status, + final String stats, + final double videoAspect, + final int streamMode, + final int? selfCodecTextureId, + final bool selfCodecReady, + final bool selfCodecSupported, + final bool recording, + final String recordStatus, + final bool pendingRecordStart, + final int selectedResolution, + final List> resolutionOptions, + final List fpsOptions, + final int currentFps, + final int lastReportedWidth, + final int lastReportedHeight, + final RTCVideoRenderer? renderer, + final String? alert, + }) = _$ConnectionSessionStateImpl; + + /// 是否已建立 WebRTC 连接(可开始远程控制)。 + @override + bool get connected; + + /// 是否正在连接信令服务器 / 建立 WebRTC。 + @override + bool get connecting; + + /// 状态提示文本。 + @override + String get status; + + /// 连接统计文本(每秒刷新)。 + @override + String get stats; + + /// 当前视频宽高比。 + @override + double get videoAspect; + + /// 当前串流模式:0=WebRTC 全托管;1=自编码。 + @override + int get streamMode; + + /// 自编码解码纹理 id。 + @override + int? get selfCodecTextureId; + + /// 自编码解码器是否就绪。 + @override + bool get selfCodecReady; + + /// 当前平台是否支持自编码硬解。 + @override + bool get selfCodecSupported; + + /// 是否正在录制远程视频。 + @override + bool get recording; + + /// 录制状态提示。 + @override + String get recordStatus; + + /// 自编码模式下请求录制时,先切回 WebRTC 标准模式再开始录制。 + @override + bool get pendingRecordStart; + + /// 当前选中的分辨率预设下标。 + @override + int get selectedResolution; + + /// 分辨率预设列表。 + @override + List> get resolutionOptions; + + /// 帧率档位(收到被控端上报后以上报列表为准)。 + @override + List get fpsOptions; + + /// 被控端当前采集帧率(0 表示尚未收到上报)。 + @override + int get currentFps; + + /// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。 + @override + int get lastReportedWidth; + + /// 被控端最近上报的实际采集尺寸。 + @override + int get lastReportedHeight; + + /// 远端视频渲染器(未连接为 null)。 + @override + RTCVideoRenderer? get renderer; + + /// 一次性提示消息(消费后由控制器置空)。 + @override + String? get alert; + + /// Create a copy of ConnectionSessionState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ConnectionSessionStateImplCopyWith<_$ConnectionSessionStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/webrtc_controller_flutter/lib/features/connection/domain/signal_message.dart b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.dart new file mode 100644 index 0000000..ed289e3 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'signal_message.freezed.dart'; +part 'signal_message.g.dart'; + +/// 信令消息模型,对应 Android 端的 SignalMessage。 +/// +/// 字段含义: +/// - [type] 消息类型:REGISTER / OFFER / ANSWER / ICE_CANDIDATE +/// - [fromDeviceId] 发送方设备 ID +/// - [toDeviceId] 接收方设备 ID +/// - [deviceType] 设备类型:CONTROLLER / CONTROLLED +/// - [payload] JSON 字符串形式的负载(SDP / ICE 候选等) +/// - [authType] 鉴权类型:CODE(动态验证码)/ PASSWORD(固定密码) +/// - [authValue] 鉴权值:动态验证码或固定密码 +@freezed +class SignalMessage with _$SignalMessage { + const SignalMessage._(); + + const factory SignalMessage({ + String? type, + String? fromDeviceId, + String? toDeviceId, + String? deviceType, + String? payload, + String? authType, + String? authValue, + }) = _SignalMessage; + + factory SignalMessage.fromJson(Map json) => + _$SignalMessageFromJson(json); + + /// 便捷构造方法:payload 为任意 Map,会自动序列化为 JSON 字符串。 + factory SignalMessage.withPayload({ + required String type, + required String fromDeviceId, + required String toDeviceId, + required String deviceType, + required Map payload, + String? authType, + String? authValue, + }) { + return SignalMessage( + type: type, + fromDeviceId: fromDeviceId, + toDeviceId: toDeviceId, + deviceType: deviceType, + payload: jsonEncode(payload), + authType: authType, + authValue: authValue, + ); + } + + @override + String toString() => jsonEncode(toJson()); +} \ No newline at end of file diff --git a/webrtc_controller_flutter/lib/features/connection/domain/signal_message.freezed.dart b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.freezed.dart new file mode 100644 index 0000000..6a27bb1 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.freezed.dart @@ -0,0 +1,309 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'signal_message.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +SignalMessage _$SignalMessageFromJson(Map json) { + return _SignalMessage.fromJson(json); +} + +/// @nodoc +mixin _$SignalMessage { + String? get type => throw _privateConstructorUsedError; + String? get fromDeviceId => throw _privateConstructorUsedError; + String? get toDeviceId => throw _privateConstructorUsedError; + String? get deviceType => throw _privateConstructorUsedError; + String? get payload => throw _privateConstructorUsedError; + String? get authType => throw _privateConstructorUsedError; + String? get authValue => throw _privateConstructorUsedError; + + /// Serializes this SignalMessage to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SignalMessage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SignalMessageCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SignalMessageCopyWith<$Res> { + factory $SignalMessageCopyWith( + SignalMessage value, + $Res Function(SignalMessage) then, + ) = _$SignalMessageCopyWithImpl<$Res, SignalMessage>; + @useResult + $Res call({ + String? type, + String? fromDeviceId, + String? toDeviceId, + String? deviceType, + String? payload, + String? authType, + String? authValue, + }); +} + +/// @nodoc +class _$SignalMessageCopyWithImpl<$Res, $Val extends SignalMessage> + implements $SignalMessageCopyWith<$Res> { + _$SignalMessageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SignalMessage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = freezed, + Object? fromDeviceId = freezed, + Object? toDeviceId = freezed, + Object? deviceType = freezed, + Object? payload = freezed, + Object? authType = freezed, + Object? authValue = freezed, + }) { + return _then( + _value.copyWith( + type: freezed == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String?, + fromDeviceId: freezed == fromDeviceId + ? _value.fromDeviceId + : fromDeviceId // ignore: cast_nullable_to_non_nullable + as String?, + toDeviceId: freezed == toDeviceId + ? _value.toDeviceId + : toDeviceId // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + payload: freezed == payload + ? _value.payload + : payload // ignore: cast_nullable_to_non_nullable + as String?, + authType: freezed == authType + ? _value.authType + : authType // ignore: cast_nullable_to_non_nullable + as String?, + authValue: freezed == authValue + ? _value.authValue + : authValue // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$SignalMessageImplCopyWith<$Res> + implements $SignalMessageCopyWith<$Res> { + factory _$$SignalMessageImplCopyWith( + _$SignalMessageImpl value, + $Res Function(_$SignalMessageImpl) then, + ) = __$$SignalMessageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String? type, + String? fromDeviceId, + String? toDeviceId, + String? deviceType, + String? payload, + String? authType, + String? authValue, + }); +} + +/// @nodoc +class __$$SignalMessageImplCopyWithImpl<$Res> + extends _$SignalMessageCopyWithImpl<$Res, _$SignalMessageImpl> + implements _$$SignalMessageImplCopyWith<$Res> { + __$$SignalMessageImplCopyWithImpl( + _$SignalMessageImpl _value, + $Res Function(_$SignalMessageImpl) _then, + ) : super(_value, _then); + + /// Create a copy of SignalMessage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = freezed, + Object? fromDeviceId = freezed, + Object? toDeviceId = freezed, + Object? deviceType = freezed, + Object? payload = freezed, + Object? authType = freezed, + Object? authValue = freezed, + }) { + return _then( + _$SignalMessageImpl( + type: freezed == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String?, + fromDeviceId: freezed == fromDeviceId + ? _value.fromDeviceId + : fromDeviceId // ignore: cast_nullable_to_non_nullable + as String?, + toDeviceId: freezed == toDeviceId + ? _value.toDeviceId + : toDeviceId // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + payload: freezed == payload + ? _value.payload + : payload // ignore: cast_nullable_to_non_nullable + as String?, + authType: freezed == authType + ? _value.authType + : authType // ignore: cast_nullable_to_non_nullable + as String?, + authValue: freezed == authValue + ? _value.authValue + : authValue // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$SignalMessageImpl extends _SignalMessage { + const _$SignalMessageImpl({ + this.type, + this.fromDeviceId, + this.toDeviceId, + this.deviceType, + this.payload, + this.authType, + this.authValue, + }) : super._(); + + factory _$SignalMessageImpl.fromJson(Map json) => + _$$SignalMessageImplFromJson(json); + + @override + final String? type; + @override + final String? fromDeviceId; + @override + final String? toDeviceId; + @override + final String? deviceType; + @override + final String? payload; + @override + final String? authType; + @override + final String? authValue; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SignalMessageImpl && + (identical(other.type, type) || other.type == type) && + (identical(other.fromDeviceId, fromDeviceId) || + other.fromDeviceId == fromDeviceId) && + (identical(other.toDeviceId, toDeviceId) || + other.toDeviceId == toDeviceId) && + (identical(other.deviceType, deviceType) || + other.deviceType == deviceType) && + (identical(other.payload, payload) || other.payload == payload) && + (identical(other.authType, authType) || + other.authType == authType) && + (identical(other.authValue, authValue) || + other.authValue == authValue)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + type, + fromDeviceId, + toDeviceId, + deviceType, + payload, + authType, + authValue, + ); + + /// Create a copy of SignalMessage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SignalMessageImplCopyWith<_$SignalMessageImpl> get copyWith => + __$$SignalMessageImplCopyWithImpl<_$SignalMessageImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SignalMessageImplToJson(this); + } +} + +abstract class _SignalMessage extends SignalMessage { + const factory _SignalMessage({ + final String? type, + final String? fromDeviceId, + final String? toDeviceId, + final String? deviceType, + final String? payload, + final String? authType, + final String? authValue, + }) = _$SignalMessageImpl; + const _SignalMessage._() : super._(); + + factory _SignalMessage.fromJson(Map json) = + _$SignalMessageImpl.fromJson; + + @override + String? get type; + @override + String? get fromDeviceId; + @override + String? get toDeviceId; + @override + String? get deviceType; + @override + String? get payload; + @override + String? get authType; + @override + String? get authValue; + + /// Create a copy of SignalMessage + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SignalMessageImplCopyWith<_$SignalMessageImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/webrtc_controller_flutter/lib/features/connection/domain/signal_message.g.dart b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.g.dart new file mode 100644 index 0000000..d8e0539 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/domain/signal_message.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'signal_message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SignalMessageImpl _$$SignalMessageImplFromJson(Map json) => + _$SignalMessageImpl( + type: json['type'] as String?, + fromDeviceId: json['fromDeviceId'] as String?, + toDeviceId: json['toDeviceId'] as String?, + deviceType: json['deviceType'] as String?, + payload: json['payload'] as String?, + authType: json['authType'] as String?, + authValue: json['authValue'] as String?, + ); + +Map _$$SignalMessageImplToJson(_$SignalMessageImpl instance) => + { + 'type': instance.type, + 'fromDeviceId': instance.fromDeviceId, + 'toDeviceId': instance.toDeviceId, + 'deviceType': instance.deviceType, + 'payload': instance.payload, + 'authType': instance.authType, + 'authValue': instance.authValue, + }; diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart new file mode 100644 index 0000000..2d4d8ea --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.dart @@ -0,0 +1,277 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/proto/control_message.pb.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/remote_controller.dart'; +import '../data/self_codec_decoder.dart'; +import '../data/video_recorder.dart'; +import '../domain/connection_session_state.dart'; + +part 'connection_controller.g.dart'; + +/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。 +@Riverpod(keepAlive: true) +class ConnectionController extends _$ConnectionController { + /// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。 + static const MethodChannel _windowChannel = MethodChannel('app/window'); + + RemoteController? _remote; + RTCVideoRenderer? _renderer; + final VideoRecorder _videoRecorder = VideoRecorder(); + + /// 记录上一次已应用的视频宽高比,避免重复调整窗口。 + double _lastResizedAspect = 0; + + @override + ConnectionSessionState build() { + ref.onDispose(() { + _videoRecorder.dispose(); + _remote?.disconnect(); + }); + return const ConnectionSessionState(status: '状态: 已停止'); + } + + void _setStatus(String status) => state = state.copyWith(status: status); + + void _alert(String message) => state = state.copyWith(alert: message); + + void _clearAlert() => state = state.copyWith(alert: null); + + /// 发起连接:先确保已登录,携带 Bearer 建立信令,成功后建立 WebRTC。 + Future connect({ + required String serverUrl, + required String targetDeviceId, + required String authType, + required String authValue, + }) async { + if (serverUrl.isEmpty || targetDeviceId.isEmpty) { + _alert('请填写服务器地址和目标设备ID'); + return; + } + final authRepository = ref.read(authRepositoryProvider); + state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...'); + + _remote = RemoteController( + serverUrl: serverUrl, + targetDeviceId: targetDeviceId, + authRepository: authRepository, + ); + _wireRemoteCallbacks(); + await _remote!.connect(authType: authType, authValue: authValue); + } + + void _wireRemoteCallbacks() { + final remote = _remote!; + remote.onStatusChanged = _setStatus; + remote.onConnectionEstablished = () { + state = state.copyWith( + connected: true, + connecting: false, + status: '状态: 已连接 - 远程控制中', + ); + }; + remote.onConnectionFailed = (error) { + state = state.copyWith(connecting: false); + _alert('连接失败:$error'); + }; + remote.onDisconnected = () { + state = state.copyWith(connected: false, connecting: false); + _setStatus('状态: 远端已断开'); + }; + remote.onIceDisconnected = (message) { + _alert(message); + disconnect(); + }; + remote.onTargetOffline = (message) { + _alert(message); + state = state.copyWith(connected: false, connecting: false); + }; + remote.onConnectionRejected = (message) { + _alert(message); + state = state.copyWith(connected: false, connecting: false); + }; + remote.onRemoteStream = (renderer) { + _renderer = renderer; + state = state.copyWith(renderer: renderer); + renderer.addListener(_onRendererUpdate); + if (state.pendingRecordStart) { + state = state.copyWith(pendingRecordStart: false); + _startRecording(); + } + }; + remote.onStats = (stats) => state = state.copyWith(stats: stats); + remote.onSelfCodecReady = (textureId) { + state = state.copyWith( + selfCodecTextureId: textureId, + selfCodecReady: true, + ); + }; + remote.onSelfCodecLost = () { + state = state.copyWith(selfCodecReady: false, selfCodecTextureId: null); + }; + remote.onStreamModeReport = (mode) { + state = state.copyWith(streamMode: mode); + if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) { + state = state.copyWith(pendingRecordStart: false); + _startRecording(); + } + }; + remote.onResolutionReported = (w, h) { + if (w > 0 && h > 0) { + final aspect = w / h; + state = state.copyWith(videoAspect: aspect); + _resizeWindowToAspect(aspect); + } + }; + remote.onFpsReport = (w, h, fps, supportedFps) { + state = state.copyWith( + lastReportedWidth: w > 0 ? w : state.lastReportedWidth, + lastReportedHeight: h > 0 ? h : state.lastReportedHeight, + currentFps: fps > 0 ? fps : state.currentFps, + fpsOptions: supportedFps.isNotEmpty ? supportedFps : state.fpsOptions, + ); + }; + remote.onSelfCodecNotSupported = () { + state = state.copyWith(selfCodecSupported: false); + _alert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。'); + }; + remote.onTokenExpired = () { + _alert('登录已失效,请重新登录后再连接。'); + _resetLogin(); + }; + remote.onForceLogout = () { + _alert('账号已在其他位置登录,已强制下线。'); + _resetLogin(); + }; + } + + Future _resetLogin() async { + await ref.read(authRepositoryProvider).clear(); + await disconnect(); + state = state.copyWith(connected: false, connecting: false); + } + + void _onRendererUpdate() { + final w = _renderer?.value.width ?? 0; + final h = _renderer?.value.height ?? 0; + if (w > 0 && h > 0) { + final aspect = w / h; + state = state.copyWith(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); + } + + /// 切换分辨率:写入状态并下发指令。 + void selectResolution(int index) { + state = state.copyWith(selectedResolution: index); + final o = state.resolutionOptions[index]; + _remote?.sendResolutionChange( + o['width'] as int, + o['height'] as int, + o['fps'] as int, + ); + } + + /// 切换帧率:仅切帧率,分辨率保持不变。 + void selectFps(int fps) { + if (fps <= 0 || fps == state.currentFps) return; + _remote?.sendResolutionChange( + state.lastReportedWidth, + state.lastReportedHeight, + fps, + ); + } + + /// 切换串流模式:WebRTC 全托管 <-> 自编码。 + void toggleStreamMode() { + if (!state.selfCodecSupported) { + _alert('当前平台不支持自编码硬解。'); + return; + } + final next = state.streamMode == SelfCodecDecoder.streamModeSelfCodec + ? SelfCodecDecoder.streamModeWebRtc + : SelfCodecDecoder.streamModeSelfCodec; + state = state.copyWith(streamMode: next); + _remote?.sendStreamMode(next); + } + + /// 切换远程视频录制:开始 / 停止。 + Future toggleRecord() async { + if (state.recording) { + await _stopRecording(); + return; + } + final renderer = _renderer; + if (renderer == null) { + _alert('尚未连接或没有视频画面,无法录制'); + return; + } + if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec) { + // 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。 + state = state.copyWith( + pendingRecordStart: true, + recordStatus: '正在切回标准模式以开始录制...', + ); + _remote?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc); + return; + } + await _startRecording(); + } + + Future _startRecording() async { + if (state.recording) return; + final stream = _renderer?.srcObject; + if (stream == null) { + _alert('尚未接收到视频画面,无法录制'); + return; + } + try { + final ok = await _videoRecorder.start(stream); + if (ok) { + state = state.copyWith(recording: true, recordStatus: '录制中...'); + } + } catch (e) { + state = state.copyWith(recordStatus: ''); + _alert('开始录制失败:$e'); + } + } + + Future _stopRecording() async { + final path = await _videoRecorder.stop(); + state = state.copyWith( + recording: false, + recordStatus: path != null ? '已保存:$path' : '录制已停止', + ); + } + + /// 发送控制指令(protobuf 二进制)。 + void sendControlCommand(ControlMessage command) { + _remote?.sendControlCommand(command); + } + + /// 断开连接并复位全部 UI 状态。 + Future disconnect() async { + await _videoRecorder.dispose(); + await _remote?.disconnect(); + _remote = null; + _renderer?.removeListener(_onRendererUpdate); + _renderer = null; + _lastResizedAspect = 0; + state = const ConnectionSessionState(status: '状态: 已停止'); + } + + /// 消费一次性提示消息(UI 展示后调用)。 + void consumeAlert() => _clearAlert(); +} diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart new file mode 100644 index 0000000..fe206d6 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/presentation/connection_controller.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connection_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$connectionControllerHash() => + r'bea162831e84bb80f7921d7c774d76e07969b023'; + +/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。 +/// +/// Copied from [ConnectionController]. +@ProviderFor(ConnectionController) +final connectionControllerProvider = + NotifierProvider.internal( + ConnectionController.new, + name: r'connectionControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$connectionControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ConnectionController = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart b/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart new file mode 100644 index 0000000..f0cdfa0 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/presentation/pages/control_page.dart @@ -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 createState() => _ControlPageState(); +} + +class _ControlPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final connection = ref.watch(connectionControllerProvider); + + ref.listen( + 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( + 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( + 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( + context: context, + builder: (ctx) => CupertinoAlertDialog( + content: Text(message), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart b/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart new file mode 100644 index 0000000..56d1d54 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/presentation/pages/setup_page.dart @@ -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 createState() => _SetupPageState(); +} + +class _SetupPageState extends ConsumerState { + 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( + 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( + 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 _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( + 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( + context: context, + builder: (ctx) => CupertinoAlertDialog( + content: Text(message), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/features/connection/presentation/widgets/auth_dialog.dart b/webrtc_controller_flutter/lib/features/connection/presentation/widgets/auth_dialog.dart new file mode 100644 index 0000000..be52daf --- /dev/null +++ b/webrtc_controller_flutter/lib/features/connection/presentation/widgets/auth_dialog.dart @@ -0,0 +1,185 @@ +import 'package:flutter/cupertino.dart'; +import 'package:webrtc_controller_flutter/l10n/app_localizations.dart'; + +/// 鉴权方式枚举。 +enum AuthMode { none, code, password } + +/// 连接鉴权对话框:选择免密 / 动态验证码 / 固定密码。 +/// +/// 返回 [AuthSelection];取消返回 null。 +Future showAuthDialog(BuildContext context) { + return showCupertinoDialog( + context: context, + builder: (ctx) => const AuthDialog(), + ); +} + +class AuthDialog extends StatefulWidget { + const AuthDialog({super.key}); + + @override + State createState() => _AuthDialogState(); +} + +class _AuthDialogState extends State { + AuthMode _selected = AuthMode.none; + final _valueController = TextEditingController(); + + @override + void dispose() { + _valueController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + Widget buildOption( + AuthMode mode, + String title, + String desc, + ) { + final selected = _selected == mode; + return GestureDetector( + onTap: () => setState(() => _selected = mode), + 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, + ), + ], + ), + ), + ); + } + + return CupertinoAlertDialog( + title: Text(l10n.authTitle), + content: Column( + children: [ + const SizedBox(height: 12), + buildOption( + AuthMode.none, + l10n.authNone, + l10n.authNoneDesc, + ), + buildOption( + AuthMode.code, + l10n.authCode, + l10n.authCodeDesc, + ), + buildOption( + AuthMode.password, + l10n.authPassword, + l10n.authPasswordDesc, + ), + const SizedBox(height: 4), + CupertinoTextField( + controller: _valueController, + enabled: _selected != AuthMode.none, + placeholder: switch (_selected) { + AuthMode.none => l10n.authNonePlaceholder, + AuthMode.password => l10n.authPasswordPlaceholder, + AuthMode.code => l10n.authCodePlaceholder, + }, + obscureText: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ], + ), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + child: Text(l10n.connect), + onPressed: () { + final val = _valueController.text.trim(); + if (_selected != AuthMode.none && val.isEmpty) { + _showAlert(context, l10n.authValueRequired); + return; + } + Navigator.of(context).pop( + AuthSelection( + mode: _selected, + value: _selected == AuthMode.none ? '' : val, + ), + ); + }, + ), + ], + ); + } + + static void _showAlert(BuildContext context, String message) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + content: Text(message), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } +} + +/// 鉴权选择结果。 +class AuthSelection { + final AuthMode mode; + final String value; + const AuthSelection({required this.mode, required this.value}); + + String get type => switch (mode) { + AuthMode.none => 'NONE', + AuthMode.code => 'CODE', + AuthMode.password => 'PASSWORD', + }; +} diff --git a/webrtc_controller_flutter/lib/l10n/app_en.arb b/webrtc_controller_flutter/lib/l10n/app_en.arb new file mode 100644 index 0000000..0e6ab28 --- /dev/null +++ b/webrtc_controller_flutter/lib/l10n/app_en.arb @@ -0,0 +1,67 @@ +{ + "@@locale": "en", + "appTitle": "WebRTC Controller", + "login": "Login", + "logout": "Logout", + "cancel": "Cancel", + "confirm": "OK", + "connect": "Connect", + "connecting": "Connecting...", + "loginAccount": "Login Account", + "connectDevice": "Connect Device", + "serverUrl": "Signal Server URL:", + "serverUrlPlaceholder": "wss://www.ttstd.com/signal", + "deviceId": "Device ID (assigned by server):", + "deviceIdPlaceholder": "Assigned by server", + "targetDeviceId": "Target Device ID:", + "targetDeviceIdPlaceholder": "Controlled device ID", + "username": "Username", + "password": "Password", + "loginFailed": "Login failed: {error}", + "usernamePasswordRequired": "Please enter username and password", + "serverAndTargetRequired": "Please enter server URL and target device ID", + "authTitle": "Connection Auth", + "authNone": "Passwordless", + "authNoneDesc": "Manual confirmation on the controlled device", + "authCode": "Dynamic Code", + "authCodeDesc": "Use a one-time dynamic code", + "authPassword": "Fixed Password", + "authPasswordDesc": "Use a fixed connection password", + "authNonePlaceholder": "No value needed", + "authPasswordPlaceholder": "Enter fixed password", + "authCodePlaceholder": "Enter dynamic code", + "authValueRequired": "Please enter the code or password", + "statusStopped": "Status: Stopped", + "statusConnectingSignal": "Status: Connecting to signal server...", + "statusConnected": "Status: Connected - remote control active", + "statusDisconnected": "Status: Remote disconnected", + "statusSignalConnected": "Status: Signal connected, waiting for registration...", + "statusRegistered": "Status: Registered ({deviceId}), establishing...", + "statusAuthFailed": "Status: Auth failed - {error}", + "statusConnectError": "Status: Connection error - {error}", + "statusTargetAccept": "Status: Target accepted, establishing connection...", + "statusTokenRefreshFailed": "Status: Token refresh failed - {error}", + "statusForceLogout": "Status: Account logged in elsewhere, force logged out", + "connectedBindings": "Bound devices: {uids}", + "resolutionSwitch": "Switch Resolution", + "fpsSwitch": "Switch FPS", + "selfCodec": "SelfCodec", + "record": "Record", + "stop": "Stop", + "recording": "Recording...", + "recordSaved": "Saved: {path}", + "recordStopped": "Recording stopped", + "recordNoStream": "Not connected or no video stream available", + "recordSwitchToStd": "Switching back to standard mode to record...", + "recordStartFailed": "Failed to start recording: {error}", + "recordNoVideoTrack": "No remote video track received", + "selfCodecNotSupported": "Self-codec hardware decoding is not supported on this platform, fell back to WebRTC media stream.", + "iceDisconnected": "ICE connection disconnected", + "targetOffline": "Target device is offline. Please confirm it is powered on and connected.", + "connectionFailed": "Connection failed: {error}", + "tokenExpired": "Session expired. Please login again.", + "forceLogout": "Account logged in elsewhere, force logged out.", + "loginFirst": "Please login before connecting", + "pleaseFillAuth": "Please enter the code or password", + "streamModeSelfCodecDesc": "Self-codec hardware decoding is not supported on this platform." +} diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations.dart b/webrtc_controller_flutter/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..3898e91 --- /dev/null +++ b/webrtc_controller_flutter/lib/l10n/app_localizations.dart @@ -0,0 +1,518 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// No description provided for @appTitle. + /// + /// In zh, this message translates to: + /// **'WebRTC 控制端'** + String get appTitle; + + /// No description provided for @login. + /// + /// In zh, this message translates to: + /// **'登录'** + String get login; + + /// No description provided for @logout. + /// + /// In zh, this message translates to: + /// **'退出登录'** + String get logout; + + /// No description provided for @cancel. + /// + /// In zh, this message translates to: + /// **'取消'** + String get cancel; + + /// No description provided for @confirm. + /// + /// In zh, this message translates to: + /// **'确定'** + String get confirm; + + /// No description provided for @connect. + /// + /// In zh, this message translates to: + /// **'连接'** + String get connect; + + /// No description provided for @connecting. + /// + /// In zh, this message translates to: + /// **'正在连接...'** + String get connecting; + + /// No description provided for @loginAccount. + /// + /// In zh, this message translates to: + /// **'登录账号'** + String get loginAccount; + + /// No description provided for @connectDevice. + /// + /// In zh, this message translates to: + /// **'连接被控设备'** + String get connectDevice; + + /// No description provided for @serverUrl. + /// + /// In zh, this message translates to: + /// **'信令服务器地址:'** + String get serverUrl; + + /// No description provided for @serverUrlPlaceholder. + /// + /// In zh, this message translates to: + /// **'wss://www.ttstd.com/signal'** + String get serverUrlPlaceholder; + + /// No description provided for @deviceId. + /// + /// In zh, this message translates to: + /// **'本机设备ID(连接后由服务端下发,无需填写):'** + String get deviceId; + + /// No description provided for @deviceIdPlaceholder. + /// + /// In zh, this message translates to: + /// **'连接后由服务端下发'** + String get deviceIdPlaceholder; + + /// No description provided for @targetDeviceId. + /// + /// In zh, this message translates to: + /// **'目标被控设备ID:'** + String get targetDeviceId; + + /// No description provided for @targetDeviceIdPlaceholder. + /// + /// In zh, this message translates to: + /// **'被控端设备ID'** + String get targetDeviceIdPlaceholder; + + /// No description provided for @username. + /// + /// In zh, this message translates to: + /// **'用户名'** + String get username; + + /// No description provided for @password. + /// + /// In zh, this message translates to: + /// **'密码'** + String get password; + + /// No description provided for @loginFailed. + /// + /// In zh, this message translates to: + /// **'登录失败:{error}'** + String loginFailed(Object error); + + /// No description provided for @usernamePasswordRequired. + /// + /// In zh, this message translates to: + /// **'请输入用户名和密码'** + String get usernamePasswordRequired; + + /// No description provided for @serverAndTargetRequired. + /// + /// In zh, this message translates to: + /// **'请填写服务器地址和目标设备ID'** + String get serverAndTargetRequired; + + /// No description provided for @authTitle. + /// + /// In zh, this message translates to: + /// **'连接鉴权'** + String get authTitle; + + /// No description provided for @authNone. + /// + /// In zh, this message translates to: + /// **'免密连接'** + String get authNone; + + /// No description provided for @authNoneDesc. + /// + /// In zh, this message translates to: + /// **'被控端弹出手动确认框,无需验证码或密码'** + String get authNoneDesc; + + /// No description provided for @authCode. + /// + /// In zh, this message translates to: + /// **'动态验证码'** + String get authCode; + + /// No description provided for @authCodeDesc. + /// + /// In zh, this message translates to: + /// **'使用一次性动态验证码鉴权'** + String get authCodeDesc; + + /// No description provided for @authPassword. + /// + /// In zh, this message translates to: + /// **'固定密码'** + String get authPassword; + + /// No description provided for @authPasswordDesc. + /// + /// In zh, this message translates to: + /// **'使用固定连接密码鉴权'** + String get authPasswordDesc; + + /// No description provided for @authNonePlaceholder. + /// + /// In zh, this message translates to: + /// **'免密连接,无需填写'** + String get authNonePlaceholder; + + /// No description provided for @authPasswordPlaceholder. + /// + /// In zh, this message translates to: + /// **'请输入固定密码'** + String get authPasswordPlaceholder; + + /// No description provided for @authCodePlaceholder. + /// + /// In zh, this message translates to: + /// **'请输入动态验证码'** + String get authCodePlaceholder; + + /// No description provided for @authValueRequired. + /// + /// In zh, this message translates to: + /// **'请输入验证码或密码'** + String get authValueRequired; + + /// No description provided for @statusStopped. + /// + /// In zh, this message translates to: + /// **'状态: 已停止'** + String get statusStopped; + + /// No description provided for @statusConnectingSignal. + /// + /// In zh, this message translates to: + /// **'状态: 正在连接信令服务器...'** + String get statusConnectingSignal; + + /// No description provided for @statusConnected. + /// + /// In zh, this message translates to: + /// **'状态: 已连接 - 远程控制中'** + String get statusConnected; + + /// No description provided for @statusDisconnected. + /// + /// In zh, this message translates to: + /// **'状态: 远端已断开'** + String get statusDisconnected; + + /// No description provided for @statusSignalConnected. + /// + /// In zh, this message translates to: + /// **'状态: 已连接信令服务器,等待注册...'** + String get statusSignalConnected; + + /// No description provided for @statusRegistered. + /// + /// In zh, this message translates to: + /// **'状态: 已注册 ({deviceId}),正在发起连接...'** + String statusRegistered(Object deviceId); + + /// No description provided for @statusAuthFailed. + /// + /// In zh, this message translates to: + /// **'状态: 认证失败 - {error}'** + String statusAuthFailed(Object error); + + /// No description provided for @statusConnectError. + /// + /// In zh, this message translates to: + /// **'状态: 连接错误 - {error}'** + String statusConnectError(Object error); + + /// No description provided for @statusTargetAccept. + /// + /// In zh, this message translates to: + /// **'状态: 被控端已接受连接,正在建立连接...'** + String get statusTargetAccept; + + /// No description provided for @statusTokenRefreshFailed. + /// + /// In zh, this message translates to: + /// **'状态: 令牌刷新失败 - {error}'** + String statusTokenRefreshFailed(Object error); + + /// No description provided for @statusForceLogout. + /// + /// In zh, this message translates to: + /// **'状态: 账号已在其他位置登录,已强制下线'** + String get statusForceLogout; + + /// No description provided for @connectedBindings. + /// + /// In zh, this message translates to: + /// **'已绑定设备: {uids}'** + String connectedBindings(Object uids); + + /// No description provided for @resolutionSwitch. + /// + /// In zh, this message translates to: + /// **'切换分辨率'** + String get resolutionSwitch; + + /// No description provided for @fpsSwitch. + /// + /// In zh, this message translates to: + /// **'切换帧率'** + String get fpsSwitch; + + /// No description provided for @selfCodec. + /// + /// In zh, this message translates to: + /// **'自编码'** + String get selfCodec; + + /// No description provided for @record. + /// + /// In zh, this message translates to: + /// **'录制'** + String get record; + + /// No description provided for @stop. + /// + /// In zh, this message translates to: + /// **'停止'** + String get stop; + + /// No description provided for @recording. + /// + /// In zh, this message translates to: + /// **'录制中...'** + String get recording; + + /// No description provided for @recordSaved. + /// + /// In zh, this message translates to: + /// **'已保存:{path}'** + String recordSaved(Object path); + + /// No description provided for @recordStopped. + /// + /// In zh, this message translates to: + /// **'录制已停止'** + String get recordStopped; + + /// No description provided for @recordNoStream. + /// + /// In zh, this message translates to: + /// **'尚未连接或没有视频画面,无法录制'** + String get recordNoStream; + + /// No description provided for @recordSwitchToStd. + /// + /// In zh, this message translates to: + /// **'正在切回标准模式以开始录制...'** + String get recordSwitchToStd; + + /// No description provided for @recordStartFailed. + /// + /// In zh, this message translates to: + /// **'开始录制失败:{error}'** + String recordStartFailed(Object error); + + /// No description provided for @recordNoVideoTrack. + /// + /// In zh, this message translates to: + /// **'尚未接收到视频画面,无法录制'** + String get recordNoVideoTrack; + + /// No description provided for @selfCodecNotSupported. + /// + /// In zh, this message translates to: + /// **'当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。'** + String get selfCodecNotSupported; + + /// No description provided for @iceDisconnected. + /// + /// In zh, this message translates to: + /// **'ICE 连接已断开'** + String get iceDisconnected; + + /// No description provided for @targetOffline. + /// + /// In zh, this message translates to: + /// **'目标被控端不在线,请确认设备已开启并连接服务器'** + String get targetOffline; + + /// No description provided for @connectionFailed. + /// + /// In zh, this message translates to: + /// **'连接失败:{error}'** + String connectionFailed(Object error); + + /// No description provided for @tokenExpired. + /// + /// In zh, this message translates to: + /// **'登录已失效,请重新登录后再连接。'** + String get tokenExpired; + + /// No description provided for @forceLogout. + /// + /// In zh, this message translates to: + /// **'账号已在其他位置登录,已强制下线。'** + String get forceLogout; + + /// No description provided for @loginFirst. + /// + /// In zh, this message translates to: + /// **'请先登录后再连接'** + String get loginFirst; + + /// No description provided for @pleaseFillAuth. + /// + /// In zh, this message translates to: + /// **'请输入验证码或密码'** + String get pleaseFillAuth; + + /// No description provided for @streamModeSelfCodecDesc. + /// + /// In zh, this message translates to: + /// **'当前平台不支持自编码硬解。'** + String get streamModeSelfCodecDesc; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..33ce158 --- /dev/null +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart @@ -0,0 +1,228 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'WebRTC Controller'; + + @override + String get login => 'Login'; + + @override + String get logout => 'Logout'; + + @override + String get cancel => 'Cancel'; + + @override + String get confirm => 'OK'; + + @override + String get connect => 'Connect'; + + @override + String get connecting => 'Connecting...'; + + @override + String get loginAccount => 'Login Account'; + + @override + String get connectDevice => 'Connect Device'; + + @override + String get serverUrl => 'Signal Server URL:'; + + @override + String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal'; + + @override + String get deviceId => 'Device ID (assigned by server):'; + + @override + String get deviceIdPlaceholder => 'Assigned by server'; + + @override + String get targetDeviceId => 'Target Device ID:'; + + @override + String get targetDeviceIdPlaceholder => 'Controlled device ID'; + + @override + String get username => 'Username'; + + @override + String get password => 'Password'; + + @override + String loginFailed(Object error) { + return 'Login failed: $error'; + } + + @override + String get usernamePasswordRequired => 'Please enter username and password'; + + @override + String get serverAndTargetRequired => + 'Please enter server URL and target device ID'; + + @override + String get authTitle => 'Connection Auth'; + + @override + String get authNone => 'Passwordless'; + + @override + String get authNoneDesc => 'Manual confirmation on the controlled device'; + + @override + String get authCode => 'Dynamic Code'; + + @override + String get authCodeDesc => 'Use a one-time dynamic code'; + + @override + String get authPassword => 'Fixed Password'; + + @override + String get authPasswordDesc => 'Use a fixed connection password'; + + @override + String get authNonePlaceholder => 'No value needed'; + + @override + String get authPasswordPlaceholder => 'Enter fixed password'; + + @override + String get authCodePlaceholder => 'Enter dynamic code'; + + @override + String get authValueRequired => 'Please enter the code or password'; + + @override + String get statusStopped => 'Status: Stopped'; + + @override + String get statusConnectingSignal => 'Status: Connecting to signal server...'; + + @override + String get statusConnected => 'Status: Connected - remote control active'; + + @override + String get statusDisconnected => 'Status: Remote disconnected'; + + @override + String get statusSignalConnected => + 'Status: Signal connected, waiting for registration...'; + + @override + String statusRegistered(Object deviceId) { + return 'Status: Registered ($deviceId), establishing...'; + } + + @override + String statusAuthFailed(Object error) { + return 'Status: Auth failed - $error'; + } + + @override + String statusConnectError(Object error) { + return 'Status: Connection error - $error'; + } + + @override + String get statusTargetAccept => + 'Status: Target accepted, establishing connection...'; + + @override + String statusTokenRefreshFailed(Object error) { + return 'Status: Token refresh failed - $error'; + } + + @override + String get statusForceLogout => + 'Status: Account logged in elsewhere, force logged out'; + + @override + String connectedBindings(Object uids) { + return 'Bound devices: $uids'; + } + + @override + String get resolutionSwitch => 'Switch Resolution'; + + @override + String get fpsSwitch => 'Switch FPS'; + + @override + String get selfCodec => 'SelfCodec'; + + @override + String get record => 'Record'; + + @override + String get stop => 'Stop'; + + @override + String get recording => 'Recording...'; + + @override + String recordSaved(Object path) { + return 'Saved: $path'; + } + + @override + String get recordStopped => 'Recording stopped'; + + @override + String get recordNoStream => 'Not connected or no video stream available'; + + @override + String get recordSwitchToStd => + 'Switching back to standard mode to record...'; + + @override + String recordStartFailed(Object error) { + return 'Failed to start recording: $error'; + } + + @override + String get recordNoVideoTrack => 'No remote video track received'; + + @override + String get selfCodecNotSupported => + 'Self-codec hardware decoding is not supported on this platform, fell back to WebRTC media stream.'; + + @override + String get iceDisconnected => 'ICE connection disconnected'; + + @override + String get targetOffline => + 'Target device is offline. Please confirm it is powered on and connected.'; + + @override + String connectionFailed(Object error) { + return 'Connection failed: $error'; + } + + @override + String get tokenExpired => 'Session expired. Please login again.'; + + @override + String get forceLogout => 'Account logged in elsewhere, force logged out.'; + + @override + String get loginFirst => 'Please login before connecting'; + + @override + String get pleaseFillAuth => 'Please enter the code or password'; + + @override + String get streamModeSelfCodecDesc => + 'Self-codec hardware decoding is not supported on this platform.'; +} diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..78d020a --- /dev/null +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,220 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => 'WebRTC 控制端'; + + @override + String get login => '登录'; + + @override + String get logout => '退出登录'; + + @override + String get cancel => '取消'; + + @override + String get confirm => '确定'; + + @override + String get connect => '连接'; + + @override + String get connecting => '正在连接...'; + + @override + String get loginAccount => '登录账号'; + + @override + String get connectDevice => '连接被控设备'; + + @override + String get serverUrl => '信令服务器地址:'; + + @override + String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal'; + + @override + String get deviceId => '本机设备ID(连接后由服务端下发,无需填写):'; + + @override + String get deviceIdPlaceholder => '连接后由服务端下发'; + + @override + String get targetDeviceId => '目标被控设备ID:'; + + @override + String get targetDeviceIdPlaceholder => '被控端设备ID'; + + @override + String get username => '用户名'; + + @override + String get password => '密码'; + + @override + String loginFailed(Object error) { + return '登录失败:$error'; + } + + @override + String get usernamePasswordRequired => '请输入用户名和密码'; + + @override + String get serverAndTargetRequired => '请填写服务器地址和目标设备ID'; + + @override + String get authTitle => '连接鉴权'; + + @override + String get authNone => '免密连接'; + + @override + String get authNoneDesc => '被控端弹出手动确认框,无需验证码或密码'; + + @override + String get authCode => '动态验证码'; + + @override + String get authCodeDesc => '使用一次性动态验证码鉴权'; + + @override + String get authPassword => '固定密码'; + + @override + String get authPasswordDesc => '使用固定连接密码鉴权'; + + @override + String get authNonePlaceholder => '免密连接,无需填写'; + + @override + String get authPasswordPlaceholder => '请输入固定密码'; + + @override + String get authCodePlaceholder => '请输入动态验证码'; + + @override + String get authValueRequired => '请输入验证码或密码'; + + @override + String get statusStopped => '状态: 已停止'; + + @override + String get statusConnectingSignal => '状态: 正在连接信令服务器...'; + + @override + String get statusConnected => '状态: 已连接 - 远程控制中'; + + @override + String get statusDisconnected => '状态: 远端已断开'; + + @override + String get statusSignalConnected => '状态: 已连接信令服务器,等待注册...'; + + @override + String statusRegistered(Object deviceId) { + return '状态: 已注册 ($deviceId),正在发起连接...'; + } + + @override + String statusAuthFailed(Object error) { + return '状态: 认证失败 - $error'; + } + + @override + String statusConnectError(Object error) { + return '状态: 连接错误 - $error'; + } + + @override + String get statusTargetAccept => '状态: 被控端已接受连接,正在建立连接...'; + + @override + String statusTokenRefreshFailed(Object error) { + return '状态: 令牌刷新失败 - $error'; + } + + @override + String get statusForceLogout => '状态: 账号已在其他位置登录,已强制下线'; + + @override + String connectedBindings(Object uids) { + return '已绑定设备: $uids'; + } + + @override + String get resolutionSwitch => '切换分辨率'; + + @override + String get fpsSwitch => '切换帧率'; + + @override + String get selfCodec => '自编码'; + + @override + String get record => '录制'; + + @override + String get stop => '停止'; + + @override + String get recording => '录制中...'; + + @override + String recordSaved(Object path) { + return '已保存:$path'; + } + + @override + String get recordStopped => '录制已停止'; + + @override + String get recordNoStream => '尚未连接或没有视频画面,无法录制'; + + @override + String get recordSwitchToStd => '正在切回标准模式以开始录制...'; + + @override + String recordStartFailed(Object error) { + return '开始录制失败:$error'; + } + + @override + String get recordNoVideoTrack => '尚未接收到视频画面,无法录制'; + + @override + String get selfCodecNotSupported => '当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。'; + + @override + String get iceDisconnected => 'ICE 连接已断开'; + + @override + String get targetOffline => '目标被控端不在线,请确认设备已开启并连接服务器'; + + @override + String connectionFailed(Object error) { + return '连接失败:$error'; + } + + @override + String get tokenExpired => '登录已失效,请重新登录后再连接。'; + + @override + String get forceLogout => '账号已在其他位置登录,已强制下线。'; + + @override + String get loginFirst => '请先登录后再连接'; + + @override + String get pleaseFillAuth => '请输入验证码或密码'; + + @override + String get streamModeSelfCodecDesc => '当前平台不支持自编码硬解。'; +} diff --git a/webrtc_controller_flutter/lib/l10n/app_zh.arb b/webrtc_controller_flutter/lib/l10n/app_zh.arb new file mode 100644 index 0000000..87282bd --- /dev/null +++ b/webrtc_controller_flutter/lib/l10n/app_zh.arb @@ -0,0 +1,67 @@ +{ + "@@locale": "zh", + "appTitle": "WebRTC 控制端", + "login": "登录", + "logout": "退出登录", + "cancel": "取消", + "confirm": "确定", + "connect": "连接", + "connecting": "正在连接...", + "loginAccount": "登录账号", + "connectDevice": "连接被控设备", + "serverUrl": "信令服务器地址:", + "serverUrlPlaceholder": "wss://www.ttstd.com/signal", + "deviceId": "本机设备ID(连接后由服务端下发,无需填写):", + "deviceIdPlaceholder": "连接后由服务端下发", + "targetDeviceId": "目标被控设备ID:", + "targetDeviceIdPlaceholder": "被控端设备ID", + "username": "用户名", + "password": "密码", + "loginFailed": "登录失败:{error}", + "usernamePasswordRequired": "请输入用户名和密码", + "serverAndTargetRequired": "请填写服务器地址和目标设备ID", + "authTitle": "连接鉴权", + "authNone": "免密连接", + "authNoneDesc": "被控端弹出手动确认框,无需验证码或密码", + "authCode": "动态验证码", + "authCodeDesc": "使用一次性动态验证码鉴权", + "authPassword": "固定密码", + "authPasswordDesc": "使用固定连接密码鉴权", + "authNonePlaceholder": "免密连接,无需填写", + "authPasswordPlaceholder": "请输入固定密码", + "authCodePlaceholder": "请输入动态验证码", + "authValueRequired": "请输入验证码或密码", + "statusStopped": "状态: 已停止", + "statusConnectingSignal": "状态: 正在连接信令服务器...", + "statusConnected": "状态: 已连接 - 远程控制中", + "statusDisconnected": "状态: 远端已断开", + "statusSignalConnected": "状态: 已连接信令服务器,等待注册...", + "statusRegistered": "状态: 已注册 ({deviceId}),正在发起连接...", + "statusAuthFailed": "状态: 认证失败 - {error}", + "statusConnectError": "状态: 连接错误 - {error}", + "statusTargetAccept": "状态: 被控端已接受连接,正在建立连接...", + "statusTokenRefreshFailed": "状态: 令牌刷新失败 - {error}", + "statusForceLogout": "状态: 账号已在其他位置登录,已强制下线", + "connectedBindings": "已绑定设备: {uids}", + "resolutionSwitch": "切换分辨率", + "fpsSwitch": "切换帧率", + "selfCodec": "自编码", + "record": "录制", + "stop": "停止", + "recording": "录制中...", + "recordSaved": "已保存:{path}", + "recordStopped": "录制已停止", + "recordNoStream": "尚未连接或没有视频画面,无法录制", + "recordSwitchToStd": "正在切回标准模式以开始录制...", + "recordStartFailed": "开始录制失败:{error}", + "recordNoVideoTrack": "尚未接收到视频画面,无法录制", + "selfCodecNotSupported": "当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。", + "iceDisconnected": "ICE 连接已断开", + "targetOffline": "目标被控端不在线,请确认设备已开启并连接服务器", + "connectionFailed": "连接失败:{error}", + "tokenExpired": "登录已失效,请重新登录后再连接。", + "forceLogout": "账号已在其他位置登录,已强制下线。", + "loginFirst": "请先登录后再连接", + "pleaseFillAuth": "请输入验证码或密码", + "streamModeSelfCodecDesc": "当前平台不支持自编码硬解。" +} diff --git a/webrtc_controller_flutter/lib/main.dart b/webrtc_controller_flutter/lib/main.dart index cf0e0e5..1b73b8d 100644 --- a/webrtc_controller_flutter/lib/main.dart +++ b/webrtc_controller_flutter/lib/main.dart @@ -1,1012 +1,12 @@ -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 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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'; +import 'app/app.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, + runApp( + const ProviderScope( + child: WebrtcControllerApp(), + ), ); - 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> _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 _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( - 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 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( - 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 _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 _ensureLoggedIn() async { - if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) { - return true; - } - final login = await _showLoginDialog(); - return login; - } - - /// 显示登录对话框:输入用户名/密码,调用 /api/auth/login 保存令牌。 - Future _showLoginDialog() async { - _usernameController.clear(); - _passwordController.clear(); - final result = await showCupertinoDialog( - 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 _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 _onIceDisconnected(String message) async { - _showAlert(message); - await _disconnect(); - } - - /// 目标被控端不在线:提示用户并复位到连接设置面板。 - Future _onTargetOffline(String message) async { - _showAlert(message); - setState(() { - _connected = false; - _connecting = false; - }); - } - - /// 被控端拒绝连接请求:提示用户并复位到连接设置面板。 - Future _onConnectionRejected(String message) async { - _showAlert(message); - setState(() { - _connected = false; - _connecting = 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('取消'), - ), - ), - ); - } - - /// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。 - void _showFpsMenu() { - showCupertinoModalPopup( - 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 _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 _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 _stopRecording() async { - final path = await _videoRecorder.stop(); - if (mounted) { - setState(() { - _recording = false; - _recordStatus = path != null ? '已保存:$path' : '录制已停止'; - }); - } - } - - Future _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(), - ), - ], - ), - ); - } } diff --git a/webrtc_controller_flutter/lib/models/signal_message.dart b/webrtc_controller_flutter/lib/models/signal_message.dart deleted file mode 100644 index 34021f3..0000000 --- a/webrtc_controller_flutter/lib/models/signal_message.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:convert'; - -/// 信令消息模型,对应 Android 端的 SignalMessage。 -/// -/// 字段含义: -/// - [type] 消息类型:REGISTER / OFFER / ANSWER / ICE_CANDIDATE -/// - [fromDeviceId] 发送方设备 ID -/// - [toDeviceId] 接收方设备 ID -/// - [deviceType] 设备类型:CONTROLLER / CONTROLLED -/// - [payload] JSON 字符串形式的负载(SDP / ICE 候选等) -/// - [authType] 鉴权类型:CODE(动态验证码)/ PASSWORD(固定密码) -/// - [authValue] 鉴权值:动态验证码或固定密码 -class SignalMessage { - final String? type; - final String? fromDeviceId; - final String? toDeviceId; - final String? deviceType; - final String? payload; - final String? authType; - final String? authValue; - - const SignalMessage({ - this.type, - this.fromDeviceId, - this.toDeviceId, - this.deviceType, - this.payload, - this.authType, - this.authValue, - }); - - factory SignalMessage.fromJson(Map json) => SignalMessage( - type: json['type'] as String?, - fromDeviceId: json['fromDeviceId'] as String?, - toDeviceId: json['toDeviceId'] as String?, - deviceType: json['deviceType'] as String?, - payload: json['payload'] as String?, - authType: json['authType'] as String?, - authValue: json['authValue'] as String?, - ); - - Map toJson() => { - if (type != null) 'type': type, - if (fromDeviceId != null) 'fromDeviceId': fromDeviceId, - if (toDeviceId != null) 'toDeviceId': toDeviceId, - if (deviceType != null) 'deviceType': deviceType, - if (payload != null) 'payload': payload, - if (authType != null) 'authType': authType, - if (authValue != null) 'authValue': authValue, - }; - - /// 便捷构造方法:payload 为任意 Map,会自动序列化为 JSON 字符串。 - factory SignalMessage.withPayload({ - required String type, - required String fromDeviceId, - required String toDeviceId, - required String deviceType, - required Map payload, - String? authType, - String? authValue, - }) { - return SignalMessage( - type: type, - fromDeviceId: fromDeviceId, - toDeviceId: toDeviceId, - deviceType: deviceType, - payload: jsonEncode(payload), - authType: authType, - authValue: authValue, - ); - } - - @override - String toString() => jsonEncode(toJson()); -} diff --git a/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift index e74479b..b082e49 100644 --- a/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,11 @@ import Foundation import device_info_plus import flutter_secure_storage_macos import flutter_webrtc +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/webrtc_controller_flutter/pubspec.lock b/webrtc_controller_flutter/pubspec.lock index 0d32142..217ead2 100644 --- a/webrtc_controller_flutter/pubspec.lock +++ b/webrtc_controller_flutter/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.flutter-io.cn" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.4" android_id: dependency: "direct main" description: @@ -33,6 +57,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.6" characters: dependency: transitive description: @@ -41,6 +129,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -57,6 +153,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -65,6 +169,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -81,6 +193,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" dart_webrtc: dependency: transitive description: @@ -105,6 +241,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.10.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" fake_async: dependency: transitive description: @@ -150,6 +302,19 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" flutter_secure_storage: dependency: "direct main" description: @@ -216,6 +381,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.5.2" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: @@ -225,13 +438,21 @@ packages: source: hosted version: "2.0.2" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -240,6 +461,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" jni: dependency: transitive description: @@ -264,6 +501,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -336,6 +589,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" objective_c: dependency: transitive description: @@ -424,6 +685,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" protobuf: dependency: "direct main" description: @@ -440,6 +709,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -448,11 +725,131 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.4" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.7" source_span: dependency: transitive description: @@ -469,6 +866,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -477,6 +882,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -509,6 +922,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -541,6 +962,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -607,4 +1036,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/webrtc_controller_flutter/pubspec.yaml b/webrtc_controller_flutter/pubspec.yaml index cb551d6..3f73df0 100644 --- a/webrtc_controller_flutter/pubspec.yaml +++ b/webrtc_controller_flutter/pubspec.yaml @@ -1,38 +1,19 @@ name: webrtc_controller_flutter -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +description: "跨平台 WebRTC 远程控制端(Flutter)。" +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: ^3.12.2 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter + # 国际化(l10n / ARB) + flutter_localizations: + sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 # 跨平台 WebRTC(同时支持 Android 与 iOS) @@ -44,12 +25,12 @@ dependencies: # WebSocket 信令通信 web_socket_channel: ^3.0.3 - # HTTP API(登录 / 刷新 / 绑定列表 / TURN 凭证) - http: ^1.2.0 - # 安全存储:refreshToken 存 Keychain / EncryptedSharedPreferences flutter_secure_storage: ^9.0.0 + # 本地存储(非敏感偏好) + shared_preferences: ^2.3.0 + # 设备 ID 生成 uuid: ^4.4.0 @@ -59,55 +40,32 @@ dependencies: fixnum: ^1.1.1 android_id: ^0.5.2+1 + # 状态管理(v2.x,注解 + 代码生成) + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + + # 路由 + go_router: ^14.0.0 + + # 网络请求(配合 json_annotation 进行 JSON 序列化) + dio: ^5.7.0 + + # 数据模型与不可变性 + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec + # 代码生成:freezed / json_serializable / riverpod_generator + build_runner: ^2.4.13 + freezed: ^2.5.7 + json_serializable: ^6.9.0 + riverpod_generator: ^2.6.1 -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + generate: true diff --git a/webrtc_controller_flutter/test/widget_test.dart b/webrtc_controller_flutter/test/widget_test.dart index 3c873c6..7b05c5a 100644 --- a/webrtc_controller_flutter/test/widget_test.dart +++ b/webrtc_controller_flutter/test/widget_test.dart @@ -1,30 +1,18 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:webrtc_controller_flutter/main.dart'; +import 'package:webrtc_controller_flutter/app/app.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + testWidgets('App builds and shows setup page', (WidgetTester tester) async { + await tester.pumpWidget( + const ProviderScope(child: WebrtcControllerApp()), + ); await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // 连接设置页(CupertinoPageScaffold + 导航栏)应正常渲染。 + expect(find.byType(CupertinoPageScaffold), findsOneWidget); + expect(find.byType(CupertinoNavigationBar), findsOneWidget); }); }