docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构
AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
@@ -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<User?> build() async {
|
||||
return _fetchUser();
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic> json) =>
|
||||
_$UserModelFromJson(json);
|
||||
const factory SignalMessage({
|
||||
String? type,
|
||||
String? payload,
|
||||
}) = _SignalMessage;
|
||||
|
||||
factory SignalMessage.fromJson(Map<String, dynamic> 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 各端。
|
||||
|
||||
@@ -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 凭据。
|
||||
|
||||
5
webrtc_controller_flutter/l10n.yaml
Normal file
5
webrtc_controller_flutter/l10n.yaml
Normal file
@@ -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
|
||||
@@ -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<Map<String, dynamic>>? _refreshing;
|
||||
|
||||
String? _accessToken;
|
||||
String? get accessToken => _accessToken;
|
||||
|
||||
/// 从安全存储恢复令牌(应用启动时调用)。
|
||||
Future<void> restore() async {
|
||||
_accessToken = await _storage.read(key: _kAccess);
|
||||
}
|
||||
|
||||
bool get hasTokens {
|
||||
// 同时读取内存 accessToken 与持久 refreshToken 判断。
|
||||
return _accessToken != null;
|
||||
}
|
||||
|
||||
Future<bool> hasRefreshToken() async =>
|
||||
(await _storage.read(key: _kRefresh)) != null;
|
||||
|
||||
Future<void> 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<String?> getUsername() => _storage.read(key: _kUsername);
|
||||
|
||||
Future<void> clear() async {
|
||||
_accessToken = null;
|
||||
await _storage.delete(key: _kAccess);
|
||||
await _storage.delete(key: _kRefresh);
|
||||
await _storage.delete(key: _kUsername);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> register(String username, String password) =>
|
||||
_post('/api/auth/register',
|
||||
body: {'username': username, 'password': password});
|
||||
|
||||
/// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _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<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||||
|
||||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回空 Map。
|
||||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||||
try {
|
||||
return await _get('/api/client/turn-credentials');
|
||||
} on ApiException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, {required Map<String, dynamic> body}) async {
|
||||
final res = await _http.post(
|
||||
Uri.parse('$kApiBase$path'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _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<String, dynamic> _handle(http.Response res) {
|
||||
final body = res.body.isNotEmpty ? jsonDecode(res.body) : <String, dynamic>{};
|
||||
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<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
/// API 调用异常,携带服务端 code 与 HTTP 状态码。
|
||||
class ApiException implements Exception {
|
||||
final String code;
|
||||
final int httpCode;
|
||||
ApiException(this.code, this.httpCode);
|
||||
|
||||
@override
|
||||
String toString() => code;
|
||||
}
|
||||
30
webrtc_controller_flutter/lib/app/app.dart
Normal file
30
webrtc_controller_flutter/lib/app/app.dart
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> 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<int> kDefaultFpsOptions = [15, 24, 30, 60];
|
||||
@@ -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<Map<String, dynamic>> kIceServers = [
|
||||
'credential': 'fht',
|
||||
},
|
||||
];
|
||||
|
||||
/// 信令 WebSocket 默认地址。
|
||||
const String kDefaultSignalServer = 'wss://www.ttstd.com/signal';
|
||||
|
||||
/// DataChannel 标签,控制端与被控端需一致。
|
||||
const String kDataChannelLabel = 'control_channel';
|
||||
21
webrtc_controller_flutter/lib/app/router/app_router.dart
Normal file
21
webrtc_controller_flutter/lib/app/router/app_router.dart
Normal file
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
11
webrtc_controller_flutter/lib/app/theme/app_theme.dart
Normal file
11
webrtc_controller_flutter/lib/app/theme/app_theme.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
/// 全局 Cupertino 主题配置。
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
/// 应用统一主题(iOS 风格)。
|
||||
static const CupertinoThemeData light = CupertinoThemeData(
|
||||
primaryColor: CupertinoColors.activeBlue,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String?> readAccess() => _storage.read(key: _kAccess);
|
||||
Future<String?> readRefresh() => _storage.read(key: _kRefresh);
|
||||
Future<String?> readUsername() => _storage.read(key: _kUsername);
|
||||
|
||||
Future<void> writeAccess(String token) =>
|
||||
_storage.write(key: _kAccess, value: token);
|
||||
Future<void> writeRefresh(String token) =>
|
||||
_storage.write(key: _kRefresh, value: token);
|
||||
Future<void> writeUsername(String username) =>
|
||||
_storage.write(key: _kUsername, value: username);
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kAccess);
|
||||
await _storage.delete(key: _kRefresh);
|
||||
await _storage.delete(key: _kUsername);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -55,7 +55,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
|
||||
// 采样率限制:控制 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<RemoteTouchView> {
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
_currentPosition = event.localPosition;
|
||||
|
||||
|
||||
if (_cachedSize != null) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
// 采样率限制逻辑
|
||||
@@ -180,5 +180,3 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthRepository>((ref) {
|
||||
return DioAuthRepository();
|
||||
});
|
||||
@@ -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<Map<String, dynamic>>? _refreshing;
|
||||
|
||||
String? _accessToken;
|
||||
|
||||
@override
|
||||
String? get accessToken => _accessToken;
|
||||
|
||||
@override
|
||||
Future<void> restore() async {
|
||||
_accessToken = await _storage.readAccess();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasRefreshToken() async =>
|
||||
await _storage.readRefresh() != null;
|
||||
|
||||
@override
|
||||
Future<String?> getUsername() => _storage.readUsername();
|
||||
|
||||
Future<void> _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<Map<String, dynamic>> 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<Map<String, dynamic>> register(String username, String password) =>
|
||||
_post('/api/auth/register',
|
||||
body: {'username': username, 'password': password});
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _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<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||||
try {
|
||||
return await _get('/api/client/turn-credentials');
|
||||
} on ApiException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_accessToken = null;
|
||||
await _storage.clear();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(
|
||||
String path, {
|
||||
required Map<String, dynamic> body,
|
||||
}) async {
|
||||
final res = await _dio.post<dynamic>(
|
||||
path,
|
||||
data: jsonEncode(body),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(String path) async {
|
||||
final res = await _dio.get<dynamic>(
|
||||
path,
|
||||
options: Options(
|
||||
headers: {
|
||||
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handle(Response<dynamic> res) {
|
||||
final data = res.data;
|
||||
final body = (data is Map)
|
||||
? data.cast<String, dynamic>()
|
||||
: <String, dynamic>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。
|
||||
abstract interface class AuthRepository {
|
||||
/// 内存中的 accessToken(掉线即失)。
|
||||
String? get accessToken;
|
||||
|
||||
/// 从安全存储恢复令牌(应用启动时调用)。
|
||||
Future<void> restore();
|
||||
|
||||
Future<bool> hasRefreshToken();
|
||||
|
||||
Future<String?> getUsername();
|
||||
|
||||
Future<Map<String, dynamic>> login(String username, String password);
|
||||
|
||||
Future<Map<String, dynamic>> register(String username, String password);
|
||||
|
||||
/// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。
|
||||
Future<Map<String, dynamic>> refresh();
|
||||
|
||||
Future<Map<String, dynamic>> verify();
|
||||
|
||||
Future<Map<String, dynamic>> bindings();
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。
|
||||
Future<Map<String, dynamic>?> turnCredentials();
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>(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<AuthState> 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;
|
||||
}
|
||||
@@ -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<AuthState> 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<bool> 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<void> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AuthController, AuthState>.internal(
|
||||
AuthController.new,
|
||||
name: r'authControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AuthController = AsyncNotifier<AuthState>;
|
||||
// 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
|
||||
@@ -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<LoginDialog> createState() => _LoginDialogState();
|
||||
}
|
||||
|
||||
class _LoginDialogState extends ConsumerState<LoginDialog> {
|
||||
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<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> _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<void> _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<void> _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<String, dynamic>.from(e as Map))
|
||||
@@ -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<String, dynamic>;
|
||||
final message = SignalMessage.fromJson(map);
|
||||
// 记录服务端下发的本机 deviceId(REGISTER_SUCCESS.fromDeviceId)。
|
||||
if (message.type?.toUpperCase() == 'REGISTER_SUCCESS' &&
|
||||
message.fromDeviceId != null) {
|
||||
// 透传给上层,由 RemoteController 处理。
|
||||
}
|
||||
onMessage?.call(message);
|
||||
} catch (_) {
|
||||
// 忽略无法解析的消息。
|
||||
@@ -48,7 +48,7 @@ class VideoRecorder {
|
||||
try {
|
||||
await _recorder!.stop();
|
||||
} catch (e) {
|
||||
// 停止失败时仍清理状态,避免界面卡在“录制中”
|
||||
// 停止失败时仍清理状态,避免界面卡在"录制中"
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] stop error: $e');
|
||||
} finally {
|
||||
@@ -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<void> _bindRemoteVideo(RTCTrackEvent event) async {
|
||||
MediaStream stream;
|
||||
if (event.streams.isNotEmpty) {
|
||||
@@ -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<Map<String, Object>> resolutionOptions,
|
||||
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
@Default(kDefaultFpsOptions) List<int> fpsOptions,
|
||||
|
||||
/// 被控端当前采集帧率(0 表示尚未收到上报)。
|
||||
@Default(0) int currentFps,
|
||||
|
||||
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
|
||||
@Default(0) int lastReportedWidth,
|
||||
|
||||
/// 被控端最近上报的实际采集尺寸。
|
||||
@Default(0) int lastReportedHeight,
|
||||
|
||||
/// 远端视频渲染器(未连接为 null)。
|
||||
RTCVideoRenderer? renderer,
|
||||
|
||||
/// 一次性提示消息(消费后由控制器置空)。
|
||||
String? alert,
|
||||
}) = _ConnectionSessionState;
|
||||
}
|
||||
@@ -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>(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<Map<String, Object>> get resolutionOptions =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
List<int> 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<ConnectionSessionState> 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<Map<String, Object>> resolutionOptions,
|
||||
List<int> 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<Map<String, Object>>,
|
||||
fpsOptions: null == fpsOptions
|
||||
? _value.fpsOptions
|
||||
: fpsOptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
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<Map<String, Object>> resolutionOptions,
|
||||
List<int> 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<Map<String, Object>>,
|
||||
fpsOptions: null == fpsOptions
|
||||
? _value._fpsOptions
|
||||
: fpsOptions // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
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<Map<String, Object>> resolutionOptions = kResolutionOptions,
|
||||
final List<int> 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<Map<String, Object>> _resolutionOptions;
|
||||
|
||||
/// 分辨率预设列表。
|
||||
@override
|
||||
@JsonKey()
|
||||
List<Map<String, Object>> get resolutionOptions {
|
||||
if (_resolutionOptions is EqualUnmodifiableListView)
|
||||
return _resolutionOptions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_resolutionOptions);
|
||||
}
|
||||
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
final List<int> _fpsOptions;
|
||||
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
@override
|
||||
@JsonKey()
|
||||
List<int> 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<Map<String, Object>> resolutionOptions,
|
||||
final List<int> 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<Map<String, Object>> get resolutionOptions;
|
||||
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
@override
|
||||
List<int> 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;
|
||||
}
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$SignalMessageFromJson(json);
|
||||
|
||||
/// 便捷构造方法:payload 为任意 Map,会自动序列化为 JSON 字符串。
|
||||
factory SignalMessage.withPayload({
|
||||
required String type,
|
||||
required String fromDeviceId,
|
||||
required String toDeviceId,
|
||||
required String deviceType,
|
||||
required Map<String, dynamic> 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());
|
||||
}
|
||||
@@ -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>(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<String, dynamic> 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<String, dynamic> 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<SignalMessage> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'signal_message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SignalMessageImpl _$$SignalMessageImplFromJson(Map<String, dynamic> 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<String, dynamic> _$$SignalMessageImplToJson(_$SignalMessageImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'type': instance.type,
|
||||
'fromDeviceId': instance.fromDeviceId,
|
||||
'toDeviceId': instance.toDeviceId,
|
||||
'deviceType': instance.deviceType,
|
||||
'payload': instance.payload,
|
||||
'authType': instance.authType,
|
||||
'authValue': instance.authValue,
|
||||
};
|
||||
@@ -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<void> 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<void> _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<void> 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<void> _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<void> _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<void> 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();
|
||||
}
|
||||
@@ -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<ConnectionController, ConnectionSessionState>.internal(
|
||||
ConnectionController.new,
|
||||
name: r'connectionControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$connectionControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ConnectionController = Notifier<ConnectionSessionState>;
|
||||
// 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
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../../core/utils/control_commands.dart';
|
||||
import '../../../../core/widgets/remote_touch_view.dart';
|
||||
import '../../data/self_codec_decoder.dart';
|
||||
import '../../domain/connection_session_state.dart';
|
||||
import '../connection_controller.dart';
|
||||
|
||||
/// 控制面板页:显示远端视频、触摸控制、顶部菜单栏与状态浮层。
|
||||
class ControlPage extends ConsumerStatefulWidget {
|
||||
const ControlPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ControlPage> createState() => _ControlPageState();
|
||||
}
|
||||
|
||||
class _ControlPageState extends ConsumerState<ControlPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if (next.alert != null) {
|
||||
final message = next.alert!;
|
||||
// 消费一次性提示消息
|
||||
Future.microtask(() {
|
||||
ref.read(connectionControllerProvider.notifier).consumeAlert();
|
||||
});
|
||||
_showAlert(message);
|
||||
}
|
||||
// 断开连接后返回设置页
|
||||
if ((prev?.connected ?? false) && !next.connected) {
|
||||
context.go('/');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: null,
|
||||
child: SafeArea(
|
||||
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
|
||||
// 以便右下角的断开按钮贴近屏幕边缘。
|
||||
top: true,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(color: CupertinoColors.black),
|
||||
_buildVideoLayer(connection),
|
||||
_buildStatusOverlay(connection),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _buildTopMenuBar(connection, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoLayer(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
final touchLayer = RemoteTouchView(
|
||||
// 禁用离散的 TOUCH/SWIPE/LONG_PRESS 指令,改用实时的 onMotionEvent 以解决重复操作问题。
|
||||
// 原始的动作流已包含完整的触摸过程,被控端系统会自动识别单击、滑动和长按。
|
||||
onTouch: (x, y) {},
|
||||
onSwipe: (x1, y1, x2, y2, d) {},
|
||||
onLongPress: (x, y) {},
|
||||
onKey: (k, a) => controller.sendControlCommand(ControlCommands.key(k, a)),
|
||||
onMotionEvent: (a, x, y) =>
|
||||
controller.sendControlCommand(ControlCommands.motionEvent(a, x, y)),
|
||||
);
|
||||
|
||||
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec &&
|
||||
state.selfCodecReady &&
|
||||
state.selfCodecTextureId != null) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: state.videoAspect,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Texture(textureId: state.selfCodecTextureId!),
|
||||
touchLayer,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.renderer != null) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: state.videoAspect,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RTCVideoView(
|
||||
state.renderer!,
|
||||
objectFit:
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||
),
|
||||
touchLayer,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildStatusOverlay(ConnectionSessionState state) {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.54),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(state.status, style: const TextStyle(color: CupertinoColors.white)),
|
||||
Text(
|
||||
state.stats,
|
||||
style: const TextStyle(color: CupertinoColors.white, fontSize: 12),
|
||||
),
|
||||
if (state.recordStatus.isNotEmpty)
|
||||
Text(
|
||||
state.recordStatus,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.systemOrange,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
|
||||
Widget _buildTopMenuBar(ConnectionSessionState state, AppLocalizations l10n) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
final label = state.resolutionOptions[state.selectedResolution]['label']
|
||||
as String;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 分辨率菜单按钮:显示当前分辨率标签
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () => _showResolutionMenu(state),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.slider_horizontal_3,
|
||||
color: CupertinoColors.white, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 帧率菜单按钮:显示被控端当前采集帧率
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () => _showFpsMenu(state),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.speedometer,
|
||||
color: CupertinoColors.white, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
state.currentFps > 0 ? '${state.currentFps}fps' : '帧率',
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: state.selfCodecSupported ? controller.toggleStreamMode : null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.selfCodec,
|
||||
style: TextStyle(
|
||||
color: state.selfCodecSupported
|
||||
? CupertinoColors.white
|
||||
: CupertinoColors.white.withValues(alpha: 0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
CupertinoSwitch(
|
||||
value: state.streamMode ==
|
||||
SelfCodecDecoder.streamModeSelfCodec,
|
||||
onChanged: state.selfCodecSupported
|
||||
? (_) => controller.toggleStreamMode()
|
||||
: null,
|
||||
activeTrackColor: CupertinoColors.activeBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 远程视频录制开关
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: controller.toggleRecord,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
state.recording
|
||||
? CupertinoIcons.stop_circle
|
||||
: CupertinoIcons.video_camera,
|
||||
color: state.recording
|
||||
? CupertinoColors.destructiveRed
|
||||
: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
state.recording ? l10n.stop : l10n.record,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
height: 22,
|
||||
color: CupertinoColors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
// 断开连接按钮
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () async {
|
||||
await ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
if (mounted) context.go('/');
|
||||
},
|
||||
child: const Icon(
|
||||
CupertinoIcons.xmark_circle_fill,
|
||||
color: CupertinoColors.destructiveRed,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
|
||||
void _showResolutionMenu(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换分辨率'),
|
||||
actions: [
|
||||
for (int i = 0; i < state.resolutionOptions.length; i++)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectResolution(i);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (i == state.selectedResolution) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(state.resolutionOptions[i]['label'] as String),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。
|
||||
void _showFpsMenu(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换帧率'),
|
||||
actions: [
|
||||
for (final fps in state.fpsOptions)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectFps(fps);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (fps == state.currentFps) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text('${fps}fps'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../../app/constants/app_constants.dart';
|
||||
import '../../../auth/presentation/auth_controller.dart';
|
||||
import '../../../auth/presentation/widgets/login_dialog.dart';
|
||||
import '../../domain/connection_session_state.dart';
|
||||
import '../connection_controller.dart';
|
||||
import '../widgets/auth_dialog.dart';
|
||||
|
||||
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
|
||||
class SetupPage extends ConsumerStatefulWidget {
|
||||
const SetupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SetupPage> createState() => _SetupPageState();
|
||||
}
|
||||
|
||||
class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
final _serverUrlController = TextEditingController(
|
||||
text: kDefaultSignalServer,
|
||||
);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_targetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState = ref.watch(authControllerProvider).valueOrNull;
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
// 连接建立后跳转控制页。
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if ((prev?.connected ?? false) == false && next.connected) {
|
||||
context.go('/control');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.appTitle),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _serverUrlController,
|
||||
placeholder: l10n.serverUrlPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _deviceIdController,
|
||||
placeholder: l10n.deviceIdPlaceholder,
|
||||
enabled: false,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _targetController,
|
||||
placeholder: l10n.targetDeviceIdPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
connection.status,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: (authState?.loggedIn ?? false)
|
||||
? () async {
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.logout();
|
||||
}
|
||||
: () async {
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const LoginDialog(),
|
||||
);
|
||||
if (ok != true && mounted) {
|
||||
ref.read(connectionControllerProvider.notifier)
|
||||
.consumeAlert();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: connection.connecting ? null : _onConnectPressed,
|
||||
child: Text(
|
||||
connection.connecting ? l10n.connecting : l10n.connectDevice,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConnectPressed() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
if (serverUrl.isEmpty || target.isEmpty) {
|
||||
_showAlert(l10n.serverAndTargetRequired);
|
||||
return;
|
||||
}
|
||||
// 连接前确保已登录(Bearer token)。
|
||||
final authState = ref.read(authControllerProvider).valueOrNull;
|
||||
final loggedIn = authState?.loggedIn ?? false;
|
||||
if (!loggedIn) {
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const LoginDialog(),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
|
||||
final selection = await showAuthDialog(context);
|
||||
if (selection == null || !mounted) return;
|
||||
|
||||
await ref.read(connectionControllerProvider.notifier).connect(
|
||||
serverUrl: serverUrl,
|
||||
targetDeviceId: target,
|
||||
authType: selection.type,
|
||||
authValue: selection.value,
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AuthSelection?> showAuthDialog(BuildContext context) {
|
||||
return showCupertinoDialog<AuthSelection>(
|
||||
context: context,
|
||||
builder: (ctx) => const AuthDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class AuthDialog extends StatefulWidget {
|
||||
const AuthDialog({super.key});
|
||||
|
||||
@override
|
||||
State<AuthDialog> createState() => _AuthDialogState();
|
||||
}
|
||||
|
||||
class _AuthDialogState extends State<AuthDialog> {
|
||||
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<void>(
|
||||
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',
|
||||
};
|
||||
}
|
||||
67
webrtc_controller_flutter/lib/l10n/app_en.arb
Normal file
67
webrtc_controller_flutter/lib/l10n/app_en.arb
Normal file
@@ -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."
|
||||
}
|
||||
518
webrtc_controller_flutter/lib/l10n/app_localizations.dart
Normal file
518
webrtc_controller_flutter/lib/l10n/app_localizations.dart
Normal file
@@ -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<AppLocalizations>(context, AppLocalizations)!;
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> 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<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
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<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['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.',
|
||||
);
|
||||
}
|
||||
228
webrtc_controller_flutter/lib/l10n/app_localizations_en.dart
Normal file
228
webrtc_controller_flutter/lib/l10n/app_localizations_en.dart
Normal file
@@ -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.';
|
||||
}
|
||||
220
webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart
Normal file
220
webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart
Normal file
@@ -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 => '当前平台不支持自编码硬解。';
|
||||
}
|
||||
67
webrtc_controller_flutter/lib/l10n/app_zh.arb
Normal file
67
webrtc_controller_flutter/lib/l10n/app_zh.arb
Normal file
@@ -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": "当前平台不支持自编码硬解。"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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());
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user