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

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

View File

@@ -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 各端。

View File

@@ -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` | WebRTCAndroid + 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_CANDIDATEpayload 为 JSON 字符串)。
- **被控端**:使用 WebRTCControlledAndroid接收 OFFER 并回传 ANSWER
`InputCommandHandler` 解析 TOUCH / SWIPE / KEY 指令。
- **ICE 服务器**:见 `lib/config/ice_servers.dart`,请按需替换为自己的 TURN 凭据。
- **ICE 服务器**:见 `lib/app/constants/ice_servers.dart`,请按需替换为自己的 TURN 凭据。

View 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

View File

@@ -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内存保存掉线即失需重新登录
/// - refreshTokenflutter_secure_storageKeychain / 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;
}

View 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,
);
}
}

View File

@@ -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];

View File

@@ -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';

View 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(),
),
],
);

View File

@@ -0,0 +1,11 @@
import 'package:flutter/cupertino.dart';
/// 全局 Cupertino 主题配置。
class AppTheme {
const AppTheme._();
/// 应用统一主题iOS 风格)。
static const CupertinoThemeData light = CupertinoThemeData(
primaryColor: CupertinoColors.activeBlue,
);
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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_TIMEOUT400ms
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> {
);
}
}

View File

@@ -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();
});

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}
}

View File

@@ -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

View File

@@ -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(),
),
],
),
);
}
}

View File

@@ -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))

View File

@@ -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);
// deviceIdREGISTER_SUCCESS.fromDeviceId
if (message.type?.toUpperCase() == 'REGISTER_SUCCESS' &&
message.fromDeviceId != null) {
// RemoteController
}
onMessage?.call(message);
} catch (_) {
//

View File

@@ -48,7 +48,7 @@ class VideoRecorder {
try {
await _recorder!.stop();
} catch (e) {
//
// "录制中"
// ignore: avoid_print
print('[VideoRecorder] stop error: $e');
} finally {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());
}

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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();
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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',
};
}

View 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."
}

View 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, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects 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.',
);
}

View 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.';
}

View 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 => '当前平台不支持自编码硬解。';
}

View 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

View File

@@ -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());
}

View File

@@ -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"))
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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);
});
}