Merge branch 'feature/client-api-integration'

This commit is contained in:
2026-08-20 10:21:54 +08:00
285 changed files with 25193 additions and 2807 deletions

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: 'http://192.168.5.224:8080',
);
/// 信令 WebSocket 默认地址。
const String kDefaultSignalServer = 'ws://192.168.5.224:8080/ws/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)下不会发起
@@ -70,9 +66,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,40 @@
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/auth/presentation/pages/splash_page.dart';
import '../../features/connection/presentation/pages/control_page.dart';
import '../../features/connection/presentation/pages/setup_page.dart';
/// 应用路由。
///
/// - `/splash`:启动页,恢复并校验令牌后重定向(初始路由)
/// - `/login`:登录页,支持通过 `message` 查询参数展示失效提示
/// - `/`:连接设置页
/// - `/control`:控制面板页
final appRouter = GoRouter(
initialLocation: '/splash',
routes: [
GoRoute(
path: '/splash',
name: 'splash',
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => LoginPage(
message: state.uri.queryParameters['message'],
),
),
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

@@ -0,0 +1,137 @@
import 'package:fixnum/fixnum.dart';
import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
///
/// 指令通过 DataChannel 以 protobuf 二进制([ControlMessage])发送,
/// 被控端(WebRTCControlled)的 InputCommandHandler 负责解析执行。
class ControlCommands {
ControlCommands._();
/// 将 Flutter 的 [LogicalKeyboardKey] 转换为 Android 标准 [KeyEvent.keyCode]。
///
/// 说明:Flutter 的 [LogicalKeyboardKey.keyId] 是 Unicode 抽象值(如字母 'w'
/// 的 keyId 为 0x77=119),而 Android 端注入按键时使用的是 [KeyEvent.keyCode]
/// (如 KEYCODE_W=50)。两者语义不同,若直接把 Flutter 的 keyId 当作 Android
/// keyCode 发给被控端,'w' 会被误识别为 KEYCODE_F8(119) 等,导致触发系统组合键
/// /功能键(屏幕乱跳、输入框无字符输入)。因此这里统一转换成 Android keyCode。
static int toAndroidKeyCode(LogicalKeyboardKey key) {
// 字母 a-z / A-Z:Android KEYCODE_A=29 起
const a = 0x61; // 'a'
const z = 0x7a; // 'z'
const au = 0x41; // 'A'
const zu = 0x5a; // 'Z'
final id = key.keyId;
if (id >= a && id <= z) return id - a + 29;
if (id >= au && id <= zu) return id - au + 29;
// 数字 0-9:Android KEYCODE_0=7 起
if (id >= 0x30 && id <= 0x39) return id - 0x30 + 7;
// 其余按键用 LogicalKeyboardKey 常量精确匹配,避免魔法数字。
// 注意:LogicalKeyboardKey 重写了 ==/hashCode,不能作为 const map 的 key,
// 因此使用普通(非 const)Map 字面量。
const home = LogicalKeyboardKey.home;
const end = LogicalKeyboardKey.end;
final map = <LogicalKeyboardKey, int>{
LogicalKeyboardKey.space: 62,
LogicalKeyboardKey.enter: 66,
LogicalKeyboardKey.numpadEnter: 66,
LogicalKeyboardKey.backspace: 67,
LogicalKeyboardKey.tab: 61,
LogicalKeyboardKey.escape: 111,
LogicalKeyboardKey.delete: 112,
home: 122,
end: 123,
LogicalKeyboardKey.pageUp: 92,
LogicalKeyboardKey.pageDown: 93,
LogicalKeyboardKey.arrowLeft: 21,
LogicalKeyboardKey.arrowUp: 19,
LogicalKeyboardKey.arrowRight: 22,
LogicalKeyboardKey.arrowDown: 20,
// 功能键 F1-F12:Android 131..142
LogicalKeyboardKey.f1: 131,
LogicalKeyboardKey.f2: 132,
LogicalKeyboardKey.f3: 133,
LogicalKeyboardKey.f4: 134,
LogicalKeyboardKey.f5: 135,
LogicalKeyboardKey.f6: 136,
LogicalKeyboardKey.f7: 137,
LogicalKeyboardKey.f8: 138,
LogicalKeyboardKey.f9: 139,
LogicalKeyboardKey.f10: 140,
LogicalKeyboardKey.f11: 141,
LogicalKeyboardKey.f12: 142,
};
return map[key] ?? 0;
}
/// 单击指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
static ControlMessage touch(double x, double y) => ControlMessage(
action: Action.TOUCH,
x: x,
y: y,
);
/// 滑动指令。坐标为相对百分比,duration 为毫秒。
static ControlMessage swipe(
double x1,
double y1,
double x2,
double y2,
int durationMs,
) =>
ControlMessage(
action: Action.SWIPE,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
duration: Int64(durationMs),
);
/// 按键指令。action: 0=按下, 1=抬起(对应 Android KeyEvent.ACTION_DOWN/UP)。
static ControlMessage key(int keyCode, int action) => ControlMessage(
action: Action.KEY,
keyCode: keyCode,
keyAction: action,
);
/// 长按指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
/// 对应 Android 端 RemoteTouchView.createLongPressCommand。
static ControlMessage longPress(double x, double y) => ControlMessage(
action: Action.LONG_PRESS,
x: x,
y: y,
);
/// 原始 MotionEvent 指令,用于实现"实时跟手"。
/// action: 0=DOWN, 1=UP, 2=MOVE(对应 Android MotionEvent.ACTION_XXX)。
static ControlMessage motionEvent(int action, double x, double y) =>
ControlMessage(
action: Action.MOTION_EVENT,
motionAction: action,
x: x,
y: y,
);
/// 分辨率切换指令。
/// [width] 目标长边/宽度(<=0 表示被控端原生分辨率);
/// [height] 目标高度(<=0 时由被控端按屏幕宽高比计算);
/// [fps] 目标帧率(<=0 表示沿用当前帧率)。
static ControlMessage setResolution(int width, int height, int fps) =>
ControlMessage(
action: Action.SET_RESOLUTION,
width: width,
height: height,
fps: fps,
);
/// 串流模式切换指令。
/// [mode] 为 0=WebRTC 全托管 / 1=自编码(自建 MediaCodec 编解码 + video DataChannel 裸流透传)。
static ControlMessage streamMode(int mode) => ControlMessage(
action: Action.SET_STREAM_MODE,
streamMode: mode,
);
}

View File

@@ -4,6 +4,8 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/utils/control_commands.dart';
/// 远端触摸控制层(对应 Android 端 RemoteTouchView)。
///
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
@@ -43,6 +45,10 @@ class RemoteTouchView extends StatefulWidget {
}
class _RemoteTouchViewState extends State<RemoteTouchView> {
/// 用于捕获物理键盘事件的焦点节点。
/// 设置 [FocusNode.skipTraversal] 避免桌面端获得焦点时绘制一圈绿色高亮边框。
final FocusNode _focusNode = FocusNode(skipTraversal: true);
Offset? _startPosition;
Offset? _currentPosition;
DateTime? _startTime;
@@ -55,7 +61,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
// 采样率限制:控制 ACTION_MOVE 的发送频率(例如每 16ms 发送一次,约 60fps)
int _lastMoveTimestamp = 0;
static const int _sampleIntervalMs = 16;
static const int _sampleIntervalMs = 16;
/// 长按触发阈值,对应 Android GestureDetector 默认的 LONG_PRESS_TIMEOUT(400ms)。
static const _longPressTimeout = Duration(milliseconds: 400);
@@ -77,19 +83,41 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
@override
void dispose() {
_cancelLongPress();
_focusNode.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
// 关闭框架的焦点高亮描边:本控件需要 autofocus 捕获物理键盘,但框架在
// 检测到键盘交互(传统高亮模式)后会给焦点节点绘制一圈绿色高亮边框。
// 将高亮策略设为 alwaysTouch 后边框不再绘制,键盘事件捕获不受影响。
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
}
@override
Widget build(BuildContext context) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
return Theme(
data: Theme.of(context).copyWith(
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Focus(
autofocus: true,
focusNode: _focusNode,
onKeyEvent: (node, event) {
// 必须将 Flutter 的逻辑键转换为 Android keyCode 再下发,否则会被控端
// 误识别为功能键/组合键(屏幕乱跳、输入框无法输入文字)。
final int androidKeyCode = ControlCommands.toAndroidKeyCode(event.logicalKey);
if (androidKeyCode == 0) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
widget.onKey(event.logicalKey.keyId, 0);
widget.onKey(androidKeyCode, 0);
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
widget.onKey(event.logicalKey.keyId, 1);
widget.onKey(androidKeyCode, 1);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@@ -124,7 +152,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
},
onPointerMove: (event) {
_currentPosition = event.localPosition;
if (_cachedSize != null) {
final now = DateTime.now().millisecondsSinceEpoch;
// 采样率限制逻辑
@@ -177,8 +205,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
child: SizedBox.expand(),
),
),
);
}
),
);
}
}

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,226 @@
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';
import '../../connection/domain/binding_device.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<List<BindingDevice>> getBindings() async {
final data = await _get('/api/client/bindings');
final list = (data['bindings'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<List<BindingDevice>> getOnlineDevices() async {
final data = await _get('/api/client/devices/online');
final list = (data['devices'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<void> redeemPairingCode(String code) async {
await _post(
'/api/client/pairing/redeem',
body: {'code': code},
auth: true,
);
}
@override
Future<Map<String, dynamic>?> turnCredentials() async {
try {
return await _get('/api/client/turn-credentials');
} on Object {
return null;
}
}
@override
Future<void> clear() async {
_accessToken = null;
await _storage.clear();
}
Future<Map<String, dynamic>> _post(
String path, {
required Map<String, dynamic> body,
bool auth = false,
}) async {
return _handle(
await _send(
() => _dio.post<dynamic>(
path,
data: jsonEncode(body),
options: Options(
headers: {
if (auth && _accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
);
}
Future<Map<String, dynamic>> _get(String path) async {
return _handle(
await _send(
() => _dio.get<dynamic>(
path,
options: Options(
headers: {
if (_accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
);
}
/// 发送请求,遇 401 时尝试用 refreshToken 续期后重试一次。
///
/// Dio 默认对非 2xx 直接抛出 [DioException](bad response),不会走到
/// [_handle],因此在异常层拦截 401 并触发刷新重试,避免已登录用户因
/// accessToken 过期而直接失败。
Future<Response<dynamic>> _send(
Future<Response<dynamic>> Function() request,
) async {
try {
return await request();
} on DioException catch (e) {
if (e.response?.statusCode == 401 && await hasRefreshToken()) {
try {
await refresh();
} on Object {
rethrow;
}
return await request();
}
rethrow;
}
}
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,37 @@
import '../../connection/domain/binding_device.dart';
/// 账号体系与自助接口抽象(对应服务端 /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<List<BindingDevice>> getBindings();
/// 获取已绑定且当前在线(信令网络活跃)的设备列表。
Future<List<BindingDevice>> getOnlineDevices();
/// 使用被控端展示的一次性配对码建立绑定关系。
Future<void> redeemPairingCode(String code);
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。
Future<Map<String, dynamic>?> turnCredentials();
Future<void> clear();
}

View File

@@ -0,0 +1,24 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.freezed.dart';
/// 登录状态。
///
/// [alert] 为一次性提示(登录失效、注册成功等),UI 展示后须调用
/// `AuthController.consumeAlert()` 消费,避免重建时重复弹出。
@freezed
class AuthState with _$AuthState {
const AuthState._();
const factory AuthState({
@Default(false) bool loggedIn,
@Default(false) bool restoring,
@Default(false) bool submitting,
String? username,
String? error,
String? alert,
}) = _AuthState;
/// 是否应禁用输入与提交按钮。
bool get busy => submitting || restoring;
}

View File

@@ -0,0 +1,274 @@
// 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;
bool get submitting => throw _privateConstructorUsedError;
String? get username => throw _privateConstructorUsedError;
String? get error => throw _privateConstructorUsedError;
String? get alert => 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,
bool submitting,
String? username,
String? error,
String? alert,
});
}
/// @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? submitting = null,
Object? username = freezed,
Object? error = freezed,
Object? alert = 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,
submitting: null == submitting
? _value.submitting
: submitting // 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?,
alert: freezed == alert
? _value.alert
: alert // 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,
bool submitting,
String? username,
String? error,
String? alert,
});
}
/// @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? submitting = null,
Object? username = freezed,
Object? error = freezed,
Object? alert = 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,
submitting: null == submitting
? _value.submitting
: submitting // 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?,
alert: freezed == alert
? _value.alert
: alert // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
class _$AuthStateImpl extends _AuthState {
const _$AuthStateImpl({
this.loggedIn = false,
this.restoring = false,
this.submitting = false,
this.username,
this.error,
this.alert,
}) : super._();
@override
@JsonKey()
final bool loggedIn;
@override
@JsonKey()
final bool restoring;
@override
@JsonKey()
final bool submitting;
@override
final String? username;
@override
final String? error;
@override
final String? alert;
@override
String toString() {
return 'AuthState(loggedIn: $loggedIn, restoring: $restoring, submitting: $submitting, username: $username, error: $error, alert: $alert)';
}
@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.submitting, submitting) ||
other.submitting == submitting) &&
(identical(other.username, username) ||
other.username == username) &&
(identical(other.error, error) || other.error == error) &&
(identical(other.alert, alert) || other.alert == alert));
}
@override
int get hashCode => Object.hash(
runtimeType,
loggedIn,
restoring,
submitting,
username,
error,
alert,
);
/// 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 extends AuthState {
const factory _AuthState({
final bool loggedIn,
final bool restoring,
final bool submitting,
final String? username,
final String? error,
final String? alert,
}) = _$AuthStateImpl;
const _AuthState._() : super._();
@override
bool get loggedIn;
@override
bool get restoring;
@override
bool get submitting;
@override
String? get username;
@override
String? get error;
@override
String? get alert;
/// 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,165 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/api_exception.dart';
import '../data/auth_providers.dart';
import '../domain/auth_repository.dart';
import '../domain/auth_state.dart';
part 'auth_controller.g.dart';
/// 登录状态控制器。
///
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
/// 以及启动期的登录态校验(verify + refresh 重试)。
@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,
username: await repository.getUsername(),
);
}
AuthState get _current => state.valueOrNull ?? const AuthState();
/// 校验本地令牌是否仍然有效(对应启动页流程)。
///
/// 无令牌直接返回 false;`verify` 失败时尝试 `refresh` 后重试一次,
/// 仍失败则清空令牌。
Future<bool> checkLoginState() async {
final repository = _repository;
await repository.restore();
if (repository.accessToken == null) {
if (!await repository.hasRefreshToken()) {
await _reset();
return false;
}
}
if (await _verifyQuietly()) {
state = AsyncData(
_current.copyWith(
loggedIn: true,
username: await repository.getUsername(),
),
);
return true;
}
// access token 失效:用 refreshToken 续期后再校验一次。
try {
await repository.refresh();
} on Object {
await _reset();
return false;
}
if (await _verifyQuietly()) {
state = AsyncData(
_current.copyWith(
loggedIn: true,
username: await repository.getUsername(),
),
);
return true;
}
await _reset();
return false;
}
Future<bool> _verifyQuietly() async {
try {
await _repository.verify();
return true;
} on Object {
return false;
}
}
Future<void> _reset() async {
final username = await _repository.getUsername();
await _repository.clear();
state = AsyncData(AuthState(username: username));
}
/// 登录:成功返回 true,失败返回 false 并写入 [AuthState.error]。
Future<bool> login(String username, String password) async {
state = AsyncData(_current.copyWith(submitting: true, error: null));
try {
await _repository.login(username, password);
state = AsyncData(AuthState(loggedIn: true, username: username));
return true;
} catch (e) {
state = AsyncData(
_current.copyWith(
submitting: false,
loggedIn: false,
error: _describe(e, '登录失败'),
),
);
return false;
}
}
/// 注册:成功后不自动登录,仅通过 [AuthState.alert] 提示前往登录。
Future<bool> register(String username, String password) async {
state = AsyncData(_current.copyWith(submitting: true, error: null));
try {
await _repository.register(username, password);
state = AsyncData(
_current.copyWith(
submitting: false,
error: null,
alert: '注册成功,请登录',
),
);
return true;
} catch (e) {
state = AsyncData(
_current.copyWith(
submitting: false,
error: _describe(e, '注册失败'),
),
);
return false;
}
}
/// 退出登录:清空令牌并复位状态(保留用户名用于回显)。
Future<void> logout() async {
await _reset();
}
/// 展示一次性提示(如从主页因登录失效跳转时携带的文案)。
void showAlert(String message) {
state = AsyncData(_current.copyWith(alert: message));
}
/// 消费一次性提示,避免重建时重复展示。
void consumeAlert() {
if (_current.alert != null) {
state = AsyncData(_current.copyWith(alert: null));
}
}
/// 清空错误信息。
void clearError() {
if (_current.error != null) {
state = AsyncData(_current.copyWith(error: null));
}
}
/// 优先展示服务端返回的错误码,其余异常回落到通用提示。
String _describe(Object e, String fallback) {
if (e is ApiException) return e.code;
return '$fallback:$e';
}
}

View File

@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authControllerHash() => r'49ebaa798497842d2219e19dbf93618e5e15f0de';
/// 登录状态控制器。
///
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
/// 以及启动期的登录态校验(verify + refresh 重试)。
///
/// 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,285 @@
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 '../../domain/auth_state.dart';
import '../auth_controller.dart';
/// 独立登录页,对应 Android 端 `activity/login/LoginActivity`。
///
/// - 用户名 / 密码双输入,均非空才允许提交;
/// - 支持注册(`POST /api/auth/register`),注册成功后停留在本页提示登录;
/// - 登录成功(`POST /api/auth/login`)后跳转设置页 `/`;
/// - 提交过程中禁用输入与按钮,展示加载指示。
///
/// 业务逻辑全部位于 [AuthController],本页仅消费状态。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key, this.message});
/// 从其他页面因登录失效跳转过来时展示的提示文案。
final String? message;
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
/// 本地校验错误(未触达网络层时的即时提示)。
String? _localError;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(authControllerProvider.notifier);
final message = widget.message;
if (message != null && message.isNotEmpty) {
notifier.showAlert(message);
}
// 回显上次登录的用户名(对应 LoginViewModel.lastUsername())。
final username = ref.read(authControllerProvider).valueOrNull?.username;
if (username != null && username.isNotEmpty) {
_usernameController.text = username;
}
});
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocus.dispose();
super.dispose();
}
/// 读取并校验输入;返回 null 表示校验未通过(错误已展示)。
({String username, String password})? _readInput(AppLocalizations l10n) {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() => _localError = l10n.usernamePasswordRequired);
return null;
}
setState(() => _localError = null);
return (username: username, password: password);
}
Future<void> _login(AppLocalizations l10n) async {
final input = _readInput(l10n);
if (input == null) return;
final ok = await ref
.read(authControllerProvider.notifier)
.login(input.username, input.password);
if (!mounted || !ok) return;
context.go('/');
}
Future<void> _register(AppLocalizations l10n) async {
final input = _readInput(l10n);
if (input == null) return;
await ref
.read(authControllerProvider.notifier)
.register(input.username, input.password);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState =
ref.watch(authControllerProvider).valueOrNull ?? const AuthState();
final busy = authState.busy;
// 一次性提示:展示后立即消费,避免重建时重复弹出。
ref.listen(authControllerProvider, (previous, next) {
final alert = next.valueOrNull?.alert;
if (alert == null || alert.isEmpty) return;
ref.read(authControllerProvider.notifier).consumeAlert();
_showAlert(context, alert, l10n);
});
final error = _localError ?? authState.error;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(middle: Text(l10n.login)),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LoginHeader(title: l10n.appTitle),
const SizedBox(height: 32),
_LoginField(
controller: _usernameController,
enabled: !busy,
placeholder: l10n.username,
icon: CupertinoIcons.person,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
),
const SizedBox(height: 12),
_LoginField(
controller: _passwordController,
focusNode: _passwordFocus,
enabled: !busy,
placeholder: l10n.password,
icon: CupertinoIcons.lock,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => busy ? null : _login(l10n),
),
if (error != null) ...[
const SizedBox(height: 12),
_ErrorText(message: error),
],
const SizedBox(height: 24),
CupertinoButton.filled(
onPressed: busy ? null : () => _login(l10n),
child: busy
? const CupertinoActivityIndicator(
color: CupertinoColors.white,
)
: Text(l10n.login),
),
const SizedBox(height: 8),
CupertinoButton(
onPressed: busy ? null : () => _register(l10n),
child: Text(l10n.registerAccount),
),
],
),
),
),
),
),
);
}
static void _showAlert(
BuildContext context,
String message,
AppLocalizations l10n,
) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.confirm),
),
],
),
);
}
}
/// 页头:图标 + 标题 + 副标题。
class _LoginHeader extends StatelessWidget {
const _LoginHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
children: [
const Icon(
CupertinoIcons.device_phone_portrait,
size: 56,
color: CupertinoColors.activeBlue,
),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
l10n.loginSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: CupertinoColors.systemGrey,
),
),
],
);
}
}
/// 带前置图标的登录输入框。
class _LoginField extends StatelessWidget {
const _LoginField({
required this.controller,
required this.enabled,
required this.placeholder,
required this.icon,
required this.textInputAction,
required this.onSubmitted,
this.focusNode,
this.obscureText = false,
});
final TextEditingController controller;
final FocusNode? focusNode;
final bool enabled;
final String placeholder;
final IconData icon;
final bool obscureText;
final TextInputAction textInputAction;
final ValueChanged<String> onSubmitted;
@override
Widget build(BuildContext context) {
return CupertinoTextField(
controller: controller,
focusNode: focusNode,
enabled: enabled,
placeholder: placeholder,
obscureText: obscureText,
autocorrect: false,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
prefix: Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(icon, size: 20, color: CupertinoColors.systemGrey),
),
);
}
}
/// 表单错误提示文案。
class _ErrorText extends StatelessWidget {
const _ErrorText({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: CupertinoColors.destructiveRed,
),
);
}
}

View File

@@ -0,0 +1,64 @@
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 '../auth_controller.dart';
/// 启动页,对应 Android 端 `activity/splash/SplashActivity`。
///
/// 流程:
/// 1. 由 [AuthController] 从安全存储恢复令牌;
/// 2. 调用 `checkLoginState()` 校验(失效时用 refreshToken 续期后重试一次);
/// 3. 校验通过跳转设置页(`/`),否则清空令牌并跳转登录页(`/login`)。
class SplashPage extends ConsumerStatefulWidget {
const SplashPage({super.key});
@override
ConsumerState<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends ConsumerState<SplashPage> {
@override
void initState() {
super.initState();
// build() 期间不能直接改动 provider 状态,推迟到首帧之后。
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
}
Future<void> _bootstrap() async {
final loggedIn =
await ref.read(authControllerProvider.notifier).checkLoginState();
if (!mounted) return;
context.go(loggedIn ? '/' : '/login');
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.device_phone_portrait,
size: 64,
color: CupertinoColors.activeBlue,
),
const SizedBox(height: 20),
Text(
l10n.appTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
const CupertinoActivityIndicator(),
],
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
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;
debugPrint('login error: $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,25 +3,43 @@ import 'dart:convert';
import 'package:flutter_webrtc/flutter_webrtc.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 deviceId;
final String targetDeviceId;
final AuthRepository authRepository;
String? token;
/// 目标被控端设备ID,在发起远程控制时指定。
String? targetDeviceId;
String? authType;
String? authValue;
late final SignalingClient _signaling;
SignalingClient? _signaling;
WebRtcController? _webRtc;
Timer? _statsTimer;
/// 信令状态变化(如“正在连接…”、“已连接…”)。
/// 本地设备ID(由服务端 REGISTER_SUCCESS 下发)。
String? _myDeviceId;
/// 信令是否已注册成功(拿到本机 deviceId)。
bool get registered => _myDeviceId != null;
/// 本机设备ID(未注册时为 null)。
String? get myDeviceId => _myDeviceId;
/// 是否已发起远程控制(WebRTC 会话存在)。
bool get controlling => _webRtc != null;
/// 信令状态变化(如"正在连接…"、"已连接…)。
void Function(String status)? onStatusChanged;
/// WebRTC 连接建立(可开始远程控制)。
@@ -67,42 +85,130 @@ class RemoteController {
/// 当前平台不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported;
/// 令牌失效(4001):用于触发刷新重连。
void Function()? onTokenExpired;
/// 强制下线(4003):用于跳回登录。
void Function()? onForceLogout;
/// 信令注册成功(下发本机 deviceId),此时可发起远程控制。
void Function(String myDeviceId)? onRegistered;
RemoteController({
required this.serverUrl,
required this.deviceId,
required this.targetDeviceId,
required this.authRepository,
this.targetDeviceId,
this.token,
this.authType,
this.authValue,
});
/// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。
void connect({String? authType, String? authValue}) {
this.authType = authType;
this.authValue = authValue;
/// 仅建立信令连接:确保 accessToken 后携带 Bearer 建立 WebSocket,
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 即完成,**不会**发起远程控制。
///
/// 发起远程控制请在注册成功后调用 [startControl]。
Future<void> connectSignaling() async {
onStatusChanged?.call('状态: 正在连接信令服务器...');
_signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId);
_signaling.onConnected = () {
onStatusChanged?.call('状态: 已连接信令服务器,正在发起连接...');
_initWebRtc();
try {
token = await _ensureToken();
} catch (e) {
onStatusChanged?.call('状态: 认证失败 - $e');
onConnectionFailed?.call(e.toString());
return;
}
final signaling = SignalingClient(serverUrl: serverUrl, token: token);
_signaling = signaling;
signaling.onConnected = () {
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
};
_signaling.onMessage = _handleSignalMessage;
_signaling.onDisconnected = () {
signaling.onMessage = _handleSignalMessage;
signaling.onDisconnected = () {
_myDeviceId = null;
onStatusChanged?.call('状态: 已断开连接');
onDisconnected?.call();
};
_signaling.onError = (error) {
signaling.onError = (error) {
onStatusChanged?.call('状态: 连接错误 - $error');
onConnectionFailed?.call(error);
};
_signaling.connect();
signaling.onTokenExpired = () async {
try {
await authRepository.refresh();
token = authRepository.accessToken;
_reconnect();
} catch (e) {
onStatusChanged?.call('状态: 令牌刷新失败 - $e');
onTokenExpired?.call();
}
};
signaling.onForceLogout = () {
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
onForceLogout?.call();
};
signaling.connect();
}
/// 发起远程控制:在信令注册成功后调用,建立 WebRTC 并向目标设备创建 Offer。
///
/// [target] 目标被控端设备ID;[authType]/[authValue] 为被控端鉴权方式。
void startControl({
required String target,
String? authType,
String? authValue,
}) {
if (!registered) {
onConnectionFailed?.call('信令未就绪,请稍后重试');
return;
}
if (_webRtc != null) return;
targetDeviceId = target;
this.authType = authType;
this.authValue = authValue;
_initWebRtc();
}
/// 停止远程控制但保持信令连接(便于重新选择设备发起连接)。
Future<void> stopControl() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
}
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
Future<String> _ensureToken() async {
final existing = authRepository.accessToken;
if (existing != null) {
try {
await authRepository.verify();
return existing;
} on ApiException catch (e) {
if (e.httpCode != 401) return existing;
}
}
final data = await authRepository.refresh();
return data['accessToken'] as String;
}
void _reconnect() {
_signaling?.disconnect();
_signaling?.connect();
}
void _initWebRtc() {
final deviceId = _myDeviceId;
final signaling = _signaling;
final target = targetDeviceId;
if (deviceId == null || signaling == null || target == null) {
onStatusChanged?.call('状态: 未获取到本机设备ID,连接中止');
return;
}
_webRtc = WebRtcController(
signaling: _signaling,
signaling: signaling,
deviceId: deviceId,
targetDeviceId: targetDeviceId,
targetDeviceId: target,
authType: authType,
authValue: authValue,
);
@@ -123,6 +229,7 @@ class RemoteController {
_webRtc!.onFpsReport =
(w, h, fps, list) => onFpsReport?.call(w, h, fps, list);
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
onStatusChanged?.call('状态: 已注册 ($deviceId),正在发起连接...');
_webRtc!.initialize().catchError((e) {
onStatusChanged?.call('状态: 连接失败 - $e');
onConnectionFailed?.call(e.toString());
@@ -130,10 +237,20 @@ class RemoteController {
}
void _handleSignalMessage(SignalMessage message) {
switch (message.type?.toUpperCase()) {
final type = message.type?.toUpperCase();
if (type == 'REGISTER_SUCCESS') {
// 服务端下发本机 deviceId,作为后续 OFFER 的 fromDeviceId。
final deviceId = message.fromDeviceId;
_myDeviceId = deviceId;
onStatusChanged?.call('状态: 已连接信令服务器 ($deviceId)');
if (deviceId != null) onRegistered?.call(deviceId);
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
_loadTurnCredentials();
return;
}
switch (type) {
case 'ANSWER':
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...');
_webRtc?.handleAnswer(payload['sdp'] as String);
break;
@@ -167,6 +284,19 @@ class RemoteController {
return payload;
}
/// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。
Future<void> _loadTurnCredentials() async {
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))
.toList();
if (servers.isNotEmpty) {
WebRtcController.iceServersOverride = servers;
}
}
}
void _startStats() {
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
@@ -191,12 +321,11 @@ class RemoteController {
Future<void> sendStreamMode(int mode) =>
_webRtc?.sendStreamMode(mode) ?? Future.value();
/// 断开连接并释放资源。
/// 断开连接并释放资源(同时关闭 WebRTC 与信令)。
Future<void> disconnect() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
_signaling.disconnect();
await stopControl();
_signaling?.disconnect();
_signaling = null;
_myDeviceId = null;
}
}

View File

@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../domain/signal_message.dart';
/// 信令客户端,封装 WebSocket 连接与消息收发。
///
/// 鉴权方式:浏览器/移动端原生 WebSocket 均可在握手阶段通过子协议
/// (Sec-WebSocket-Protocol)传递 Bearer token:
/// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.`<token>`'])
/// 服务端从首子协议取出 auth.`<token>` 进行校验。
///
/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
class SignalingClient {
final String serverUrl;
final String? token;
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
/// 收到信令消息回调(已解析为 SignalMessage)。
void Function(SignalMessage message)? onMessage;
/// 连接成功回调。
void Function()? onConnected;
/// 连接断开回调。
void Function()? onDisconnected;
/// 连接错误回调。
void Function(String error)? onError;
/// 令牌失效(关闭码 4001):调用方应刷新令牌后重连。
void Function()? onTokenExpired;
/// 强制下线(关闭码 4003):调用方应停止重连并跳登录。
void Function()? onForceLogout;
SignalingClient({required this.serverUrl, this.token});
/// 建立 WebSocket 连接(携带 Bearer 子协议,不再发送 REGISTER)。
void connect() {
try {
final uri = Uri.parse(serverUrl);
final protocols = ['signal.v1'];
if (token != null && token!.isNotEmpty) {
protocols.add('auth.$token');
}
_channel = WebSocketChannel.connect(uri, protocols: protocols);
_channel!.stream.listen(
_onData,
onDone: _onDone,
onError: (Object e) => onError?.call(e.toString()),
cancelOnError: false,
);
_startHeartbeat();
onConnected?.call();
} catch (e) {
onError?.call(e.toString());
}
}
void _onData(dynamic data) {
if (data is! String) return;
try {
final map = jsonDecode(data) as Map<String, dynamic>;
final message = SignalMessage.fromJson(map);
onMessage?.call(message);
} catch (_) {
// 忽略无法解析的消息。
}
}
void _onDone() {
_stopHeartbeat();
final code = _channel?.closeCode;
if (code == 4001) {
onTokenExpired?.call();
return;
}
if (code == 4003) {
onForceLogout?.call();
return;
}
onDisconnected?.call();
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) {
if (_channel != null) {
try {
_channel!.sink.add(jsonEncode({'type': 'PING'}));
} catch (_) {}
}
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
/// 发送信令消息。
void send(SignalMessage message) => _send(message);
void _send(SignalMessage message) {
_channel?.sink.add(jsonEncode(message.toJson()));
}
/// 关闭连接。
void disconnect() {
_stopHeartbeat();
_channel?.sink.close();
_channel = null;
}
bool get isConnected => _channel != null;
}

View File

@@ -10,8 +10,8 @@ import 'package:path_provider/path_provider.dart';
/// 无需自行触碰原生 VideoSink。
///
/// 录制文件保存位置:
/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
/// - iOS:<App>/Documents/WebRTCRecordings/
/// - Android:/Android/data/&lt;包名&gt;/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
/// - iOS:&lt;App&gt;/Documents/WebRTCRecordings/
class VideoRecorder {
MediaRecorder? _recorder;
bool _recording = false;
@@ -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 {
@@ -88,6 +88,9 @@ class WebRtcController {
static const int streamModeWebRtc = 0;
static const int streamModeSelfCodec = 1;
/// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空则用 kIceServers)。
static List<Map<String, dynamic>>? iceServersOverride;
WebRtcController({
required this.signaling,
required this.deviceId,
@@ -140,7 +143,7 @@ class WebRtcController {
);
// 创建控制用 DataChannel。
// 使用非可靠、无序模式以降低延迟,解决“不跟手”问题。
// 使用非可靠、无序模式以降低延迟,解决"不跟手"问题。
final dcInit = RTCDataChannelInit()
..ordered = false
..maxRetransmits = 0;
@@ -250,7 +253,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) {
@@ -522,7 +525,7 @@ class WebRtcController {
/// 通过 DataChannel 发送控制指令(protobuf 二进制)。
void sendControlCommand(ControlMessage command) {
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
debugPrint('WebRtcController: Sending command -> ${command.action}');
debugPrint('WebRtcController: Sending command -> ${command.action} keyCode ${command.keyCode}');
_dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
}
}

View File

@@ -0,0 +1,63 @@
import 'package:json_annotation/json_annotation.dart';
part 'binding_device.g.dart';
/// 主控端已绑定的被控设备(对应服务端 DeviceBinding / 在线设备列表)。
///
/// 字段对齐 [WebRTCSignalServer] 的 [DeviceBinding] 与 OnDeviceDto,
/// 以及 Android 控制端的 [BindingItem],供用户在列表中选取并连接。
@JsonSerializable(fieldRename: FieldRename.snake)
class BindingDevice {
const BindingDevice({
required this.deviceUid,
this.name,
this.online = false,
this.authType = 'NONE',
this.lastSeen,
});
/// 被控端设备唯一标识(连接信令时使用)。
///
/// 服务端 [DeviceBinding.deviceUid](必返回,缺失时兜底空串以保证列表可用)。
@JsonKey(name: 'deviceUid')
final String deviceUid;
/// 设备展示名称,对应服务端 [DeviceBinding.alias],可为 null。
///
/// 未设置别名时回退为设备标识,避免空名称影响展示与选择。
@JsonKey(name: 'alias')
final String? name;
/// 是否在线(信令网络中活跃)。
final bool online;
/// 连接鉴权方式:NONE / PASSWORD / CODE。
///
/// 服务端字段为 [DeviceBinding.authType],缺失时默认 NONE。
@JsonKey(name: 'authType', defaultValue: 'NONE')
final String authType;
/// 最近一次在线时间戳(毫秒)。
@JsonKey(name: 'lastSeen')
final int? lastSeen;
factory BindingDevice.fromJson(Map<String, dynamic> json) =>
_$BindingDeviceFromJson(json);
Map<String, dynamic> toJson() => _$BindingDeviceToJson(this);
BindingDevice copyWith({
String? deviceUid,
String? name,
bool? online,
String? authType,
int? lastSeen,
}) =>
BindingDevice(
deviceUid: deviceUid ?? this.deviceUid,
name: name ?? this.name,
online: online ?? this.online,
authType: authType ?? this.authType,
lastSeen: lastSeen ?? this.lastSeen,
);
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'binding_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BindingDevice _$BindingDeviceFromJson(Map<String, dynamic> json) =>
BindingDevice(
deviceUid: json['deviceUid'] as String? ?? '',
name: json['alias'] as String? ?? json['deviceUid'] as String? ?? '',
online: json['online'] as bool? ?? false,
authType: json['authType'] as String? ?? 'NONE',
lastSeen: json['lastSeen'] as int?,
);
Map<String, dynamic> _$BindingDeviceToJson(BindingDevice instance) =>
<String, dynamic>{
'deviceUid': instance.deviceUid,
'alias': instance.name,
'online': instance.online,
'authType': instance.authType,
'lastSeen': instance.lastSeen,
};

View File

@@ -0,0 +1,100 @@
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../app/constants/app_constants.dart';
import 'binding_device.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,
/// 信令(WebSocket)是否已连接并注册成功。
@Default(false) bool signalingConnected,
/// 信令注册后服务端下发的本机设备ID。
String? myDeviceId,
/// 状态提示文本。
@Default('') String status,
/// 主控端已绑定的被控设备列表(供用户选择连接)。
@Default(<BindingDevice>[]) List<BindingDevice> bindings,
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
@Default(<String>{}) Set<String> onlineUids,
/// 用户在绑定设备列表中选中的目标设备 uid。
String? selectedDeviceUid,
/// 是否正在拉取绑定设备列表 / 在线状态。
@Default(false) bool deviceRefreshing,
/// 设备列表加载失败时的错误提示(null 表示无错误)。
String? deviceError,
/// 是否正在兑换配对码建立绑定。
@Default(false) bool redeeming,
/// 配对码兑换失败提示(null 表示无错误)。
String? redeemError,
/// 连接统计文本(每秒刷新)。
@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,
}) = _$ConnectionSessionStateImpl;
}

View File

@@ -0,0 +1,778 @@
// 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, unnecessary_null_comparison
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',
);
ConnectionSessionState _$ConnectionSessionStateFromJson(Map<String, dynamic> json) {
return ConnectionSessionState(
connected: json['connected'] as bool? ?? false,
connecting: json['connecting'] as bool? ?? false,
signalingConnected: json['signalingConnected'] as bool? ?? false,
myDeviceId: json['myDeviceId'] as String?,
status: json['status'] as String? ?? '',
bindings: (json['bindings'] as List?)?.cast<BindingDevice>() ?? const [],
onlineUids: (json['onlineUids'] as Set?)?.cast<String>() ?? const {},
selectedDeviceUid: json['selectedDeviceUid'] as String?,
deviceRefreshing: json['deviceRefreshing'] as bool? ?? false,
deviceError: json['deviceError'] as String?,
redeeming: json['redeeming'] as bool? ?? false,
redeemError: json['redeemError'] as String?,
stats: json['stats'] as String? ?? '',
videoAspect: json['videoAspect'] as double? ?? 16 / 9,
streamMode: json['streamMode'] as int? ?? 0,
selfCodecTextureId: json['selfCodecTextureId'] as int?,
selfCodecReady: json['selfCodecReady'] as bool? ?? false,
selfCodecSupported: json['selfCodecSupported'] as bool? ?? true,
recording: json['recording'] as bool? ?? false,
recordStatus: json['recordStatus'] as String? ?? '',
pendingRecordStart: json['pendingRecordStart'] as bool? ?? false,
selectedResolution: json['selectedResolution'] as int? ?? 0,
resolutionOptions: (json['resolutionOptions'] as List?)?.cast<Map<String, Object>>() ?? kResolutionOptions,
fpsOptions: (json['fpsOptions'] as List?)?.cast<int>() ?? kDefaultFpsOptions,
currentFps: json['currentFps'] as int? ?? 0,
lastReportedWidth: json['lastReportedWidth'] as int? ?? 0,
lastReportedHeight: json['lastReportedHeight'] as int? ?? 0,
renderer: json['renderer'] as RTCVideoRenderer?,
alert: json['alert'] as String?,
);
}
Map<String, dynamic> _$ConnectionSessionStateToJson(ConnectionSessionState instance) {
final val = <String, dynamic>{};
val['connected'] = instance.connected;
val['connecting'] = instance.connecting;
val['signalingConnected'] = instance.signalingConnected;
val['myDeviceId'] = instance.myDeviceId;
val['status'] = instance.status;
val['bindings'] = instance.bindings;
val['onlineUids'] = instance.onlineUids;
val['selectedDeviceUid'] = instance.selectedDeviceUid;
val['deviceRefreshing'] = instance.deviceRefreshing;
val['deviceError'] = instance.deviceError;
val['redeeming'] = instance.redeeming;
val['redeemError'] = instance.redeemError;
val['stats'] = instance.stats;
val['videoAspect'] = instance.videoAspect;
val['streamMode'] = instance.streamMode;
val['selfCodecTextureId'] = instance.selfCodecTextureId;
val['selfCodecReady'] = instance.selfCodecReady;
val['selfCodecSupported'] = instance.selfCodecSupported;
val['recording'] = instance.recording;
val['recordStatus'] = instance.recordStatus;
val['pendingRecordStart'] = instance.pendingRecordStart;
val['selectedResolution'] = instance.selectedResolution;
val['resolutionOptions'] = instance.resolutionOptions;
val['fpsOptions'] = instance.fpsOptions;
val['currentFps'] = instance.currentFps;
val['lastReportedWidth'] = instance.lastReportedWidth;
val['lastReportedHeight'] = instance.lastReportedHeight;
val['renderer'] = instance.renderer;
val['alert'] = instance.alert;
return val;
}
/// @nodoc
mixin _$ConnectionSessionState {
/// 是否已建立 WebRTC 连接(可开始远程控制)。
bool get connected => throw _privateConstructorUsedError;
/// 是否正在连接信令服务器 / 建立 WebRTC。
bool get connecting => throw _privateConstructorUsedError;
/// 信令(WebSocket)是否已连接并注册成功。
bool get signalingConnected => throw _privateConstructorUsedError;
/// 信令注册后服务端下发的本机设备ID。
String? get myDeviceId => throw _privateConstructorUsedError;
/// 状态提示文本。
String get status => throw _privateConstructorUsedError;
/// 主控端已绑定的被控设备列表(供用户选择连接)。
List<BindingDevice> get bindings => throw _privateConstructorUsedError;
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
Set<String> get onlineUids => throw _privateConstructorUsedError;
/// 用户在绑定设备列表中选中的目标设备 uid。
String? get selectedDeviceUid => throw _privateConstructorUsedError;
/// 是否正在拉取绑定设备列表 / 在线状态。
bool get deviceRefreshing => throw _privateConstructorUsedError;
/// 设备列表加载失败时的错误提示(null 表示无错误)。
String? get deviceError => throw _privateConstructorUsedError;
/// 是否正在兑换配对码建立绑定。
bool get redeeming => throw _privateConstructorUsedError;
/// 配对码兑换失败提示(null 表示无错误)。
String? get redeemError => 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,
bool signalingConnected,
String? myDeviceId,
String status,
List<BindingDevice> bindings,
Set<String> onlineUids,
String? selectedDeviceUid,
bool deviceRefreshing,
String? deviceError,
bool redeeming,
String? redeemError,
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;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connected = null,
Object? connecting = null,
Object? signalingConnected = null,
Object? myDeviceId = freezed,
Object? status = null,
Object? bindings = null,
Object? onlineUids = null,
Object? selectedDeviceUid = freezed,
Object? deviceRefreshing = null,
Object? deviceError = freezed,
Object? redeeming = null,
Object? redeemError = freezed,
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,
signalingConnected: null == signalingConnected
? _value.signalingConnected
: signalingConnected // ignore: cast_nullable_to_non_nullable
as bool,
myDeviceId: freezed == myDeviceId
? _value.myDeviceId
: myDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as String,
bindings: null == bindings
? _value.bindings
: bindings // ignore: cast_nullable_to_non_nullable
as List<BindingDevice>,
onlineUids: null == onlineUids
? _value.onlineUids
: onlineUids // ignore: cast_nullable_to_non_nullable
as Set<String>,
selectedDeviceUid: freezed == selectedDeviceUid
? _value.selectedDeviceUid
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
as String?,
deviceRefreshing: null == deviceRefreshing
? _value.deviceRefreshing
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
as bool,
deviceError: freezed == deviceError
? _value.deviceError
: deviceError // ignore: cast_nullable_to_non_nullable
as String?,
redeeming: null == redeeming
? _value.redeeming
: redeeming // ignore: cast_nullable_to_non_nullable
as bool,
redeemError: freezed == redeemError
? _value.redeemError
: redeemError // 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
class _$ConnectionSessionStateImpl with _$ConnectionSessionState
implements ConnectionSessionState {
const _$ConnectionSessionStateImpl({
this.connected = false,
this.connecting = false,
this.signalingConnected = false,
this.myDeviceId,
this.status = '',
this.bindings = const [],
this.onlineUids = const {},
this.selectedDeviceUid,
this.deviceRefreshing = false,
this.deviceError,
this.redeeming = false,
this.redeemError,
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,
this.resolutionOptions = kResolutionOptions,
this.fpsOptions = kDefaultFpsOptions,
this.currentFps = 0,
this.lastReportedWidth = 0,
this.lastReportedHeight = 0,
this.renderer,
this.alert,
});
@override
@JsonKey()
final bool connected;
@override
@JsonKey()
final bool connecting;
@override
@JsonKey()
final bool signalingConnected;
@override
@JsonKey()
final String? myDeviceId;
@override
@JsonKey()
final String status;
@override
@JsonKey()
final List<BindingDevice> bindings;
@override
@JsonKey()
final Set<String> onlineUids;
@override
@JsonKey()
final String? selectedDeviceUid;
@override
@JsonKey()
final bool deviceRefreshing;
@override
@JsonKey()
final String? deviceError;
@override
@JsonKey()
final bool redeeming;
@override
@JsonKey()
final String? redeemError;
@override
@JsonKey()
final String stats;
@override
@JsonKey()
final double videoAspect;
@override
@JsonKey()
final int streamMode;
@override
@JsonKey()
final int? selfCodecTextureId;
@override
@JsonKey()
final bool selfCodecReady;
@override
@JsonKey()
final bool selfCodecSupported;
@override
@JsonKey()
final bool recording;
@override
@JsonKey()
final String recordStatus;
@override
@JsonKey()
final bool pendingRecordStart;
@override
@JsonKey()
final int selectedResolution;
@override
@JsonKey()
final List<Map<String, Object>> resolutionOptions;
@override
@JsonKey()
final List<int> fpsOptions;
@override
@JsonKey()
final int currentFps;
@override
@JsonKey()
final int lastReportedWidth;
@override
@JsonKey()
final int lastReportedHeight;
@override
@JsonKey()
final RTCVideoRenderer? renderer;
@override
@JsonKey()
final String? alert;
@override
$ConnectionSessionStateCopyWith<ConnectionSessionState> get copyWith =>
__$$ConnectionSessionStateImplCopyWithImpl<ConnectionSessionState>(
this,
(value) => value,
);
Map<String, dynamic> toJson() => _$ConnectionSessionStateToJson(this);
@override
String toString() {
return 'ConnectionSessionState(${[
if (connected != null) 'connected: $connected',
if (connecting != null) 'connecting: $connecting',
if (signalingConnected != null) 'signalingConnected: $signalingConnected',
if (myDeviceId != null) 'myDeviceId: $myDeviceId',
if (status != null) 'status: $status',
if (bindings != null) 'bindings: $bindings',
if (onlineUids != null) 'onlineUids: $onlineUids',
if (selectedDeviceUid != null) 'selectedDeviceUid: $selectedDeviceUid',
if (deviceRefreshing != null) 'deviceRefreshing: $deviceRefreshing',
if (deviceError != null) 'deviceError: $deviceError',
if (redeeming != null) 'redeeming: $redeeming',
if (redeemError != null) 'redeemError: $redeemError',
if (stats != null) 'stats: $stats',
if (videoAspect != null) 'videoAspect: $videoAspect',
if (streamMode != null) 'streamMode: $streamMode',
if (selfCodecTextureId != null) 'selfCodecTextureId: $selfCodecTextureId',
if (selfCodecReady != null) 'selfCodecReady: $selfCodecReady',
if (selfCodecSupported != null) 'selfCodecSupported: $selfCodecSupported',
if (recording != null) 'recording: $recording',
if (recordStatus != null) 'recordStatus: $recordStatus',
if (pendingRecordStart != null) 'pendingRecordStart: $pendingRecordStart',
if (selectedResolution != null) 'selectedResolution: $selectedResolution',
if (resolutionOptions != null) 'resolutionOptions: $resolutionOptions',
if (fpsOptions != null) 'fpsOptions: $fpsOptions',
if (currentFps != null) 'currentFps: $currentFps',
if (lastReportedWidth != null) 'lastReportedWidth: $lastReportedWidth',
if (lastReportedHeight != null) 'lastReportedHeight: $lastReportedHeight',
if (renderer != null) 'renderer: $renderer',
if (alert != null) 'alert: $alert',
].where((t) => t != null).join(', ')})';
}
@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.signalingConnected, signalingConnected) || other.signalingConnected == signalingConnected)
&& (identical(other.myDeviceId, myDeviceId) || other.myDeviceId == myDeviceId)
&& (identical(other.status, status) || other.status == status)
&& (identical(other.bindings, bindings) || other.bindings == bindings)
&& (identical(other.onlineUids, onlineUids) || other.onlineUids == onlineUids)
&& (identical(other.selectedDeviceUid, selectedDeviceUid) || other.selectedDeviceUid == selectedDeviceUid)
&& (identical(other.deviceRefreshing, deviceRefreshing) || other.deviceRefreshing == deviceRefreshing)
&& (identical(other.deviceError, deviceError) || other.deviceError == deviceError)
&& (identical(other.redeeming, redeeming) || other.redeeming == redeeming)
&& (identical(other.redeemError, redeemError) || other.redeemError == redeemError)
&& (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)
&& (identical(other.resolutionOptions, resolutionOptions) || other.resolutionOptions == resolutionOptions)
&& (identical(other.fpsOptions, fpsOptions) || 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 => connected.hashCode ^ connecting.hashCode ^ signalingConnected.hashCode ^ myDeviceId.hashCode ^ status.hashCode ^ bindings.hashCode ^ onlineUids.hashCode ^ selectedDeviceUid.hashCode ^ deviceRefreshing.hashCode ^ deviceError.hashCode ^ redeeming.hashCode ^ redeemError.hashCode ^ stats.hashCode ^ videoAspect.hashCode ^ streamMode.hashCode ^ selfCodecTextureId.hashCode ^ selfCodecReady.hashCode ^ selfCodecSupported.hashCode ^ recording.hashCode ^ recordStatus.hashCode ^ pendingRecordStart.hashCode ^ selectedResolution.hashCode ^ resolutionOptions.hashCode ^ fpsOptions.hashCode ^ currentFps.hashCode ^ lastReportedWidth.hashCode ^ lastReportedHeight.hashCode ^ renderer.hashCode ^ alert.hashCode;
}
/// @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,
bool signalingConnected,
String? myDeviceId,
String status,
List<BindingDevice> bindings,
Set<String> onlineUids,
String? selectedDeviceUid,
bool deviceRefreshing,
String? deviceError,
bool redeeming,
String? redeemError,
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);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connected = null,
Object? connecting = null,
Object? signalingConnected = null,
Object? myDeviceId = freezed,
Object? status = null,
Object? bindings = null,
Object? onlineUids = null,
Object? selectedDeviceUid = freezed,
Object? deviceRefreshing = null,
Object? deviceError = freezed,
Object? redeeming = null,
Object? redeemError = freezed,
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,
signalingConnected: null == signalingConnected
? _value.signalingConnected
: signalingConnected // ignore: cast_nullable_to_non_nullable
as bool,
myDeviceId: freezed == myDeviceId
? _value.myDeviceId
: myDeviceId // ignore: cast_nullable_to_non_nullable
as String?,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as String,
bindings: null == bindings
? _value.bindings
: bindings // ignore: cast_nullable_to_non_nullable
as List<BindingDevice>,
onlineUids: null == onlineUids
? _value.onlineUids
: onlineUids // ignore: cast_nullable_to_non_nullable
as Set<String>,
selectedDeviceUid: freezed == selectedDeviceUid
? _value.selectedDeviceUid
: selectedDeviceUid // ignore: cast_nullable_to_non_nullable
as String?,
deviceRefreshing: null == deviceRefreshing
? _value.deviceRefreshing
: deviceRefreshing // ignore: cast_nullable_to_non_nullable
as bool,
deviceError: freezed == deviceError
? _value.deviceError
: deviceError // ignore: cast_nullable_to_non_nullable
as String?,
redeeming: null == redeeming
? _value.redeeming
: redeeming // ignore: cast_nullable_to_non_nullable
as bool,
redeemError: freezed == redeemError
? _value.redeemError
: redeemError // 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?,
),
);
}
}

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,428 @@
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/binding_device.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);
/// 仅建立信令(WebSocket)连接,**不**发起远程控制。
///
/// 进入设置页即可调用;重复调用会被忽略。
Future<void> connectSignaling({required String serverUrl}) async {
if (serverUrl.isEmpty) {
_alert('信令服务器地址为空');
return;
}
if (_remote != null || state.signalingConnected) return;
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...');
_remote = RemoteController(
serverUrl: serverUrl,
authRepository: authRepository,
);
_wireRemoteCallbacks();
await _remote!.connectSignaling();
}
/// 发起远程控制:需信令已就绪,向目标设备创建 Offer。
Future<void> startControl({
required String targetDeviceId,
required String authType,
required String authValue,
}) async {
if (targetDeviceId.isEmpty) {
_alert('请填写目标设备ID');
return;
}
if (!state.signalingConnected || _remote == null) {
_alert('信令未就绪,请稍后重试');
return;
}
state = state.copyWith(connecting: true, status: '状态: 正在发起连接...');
_remote!.startControl(
target: targetDeviceId,
authType: authType,
authValue: authValue,
);
}
/// 拉取已绑定设备列表,并合并在线设备状态。
///
/// 参考 Android 控制端的 [MainViewModel.loadBindings] / [getOnlineDevices],
/// 用在线列表标记绑定设备是否可立即连接。
Future<void> loadDevices() async {
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(deviceRefreshing: true, deviceError: null);
try {
final bindings = await authRepository.getBindings();
List<BindingDevice> online = const [];
try {
online = await authRepository.getOnlineDevices();
} on Object {
// 在线列表接口失败时,仍展示全部绑定(仅不标记在线)。
}
final onlineUids = {for (final d in online) d.deviceUid};
final merged = [
for (final b in bindings)
b.copyWith(online: onlineUids.contains(b.deviceUid)),
];
final stillSelected = merged
.where((b) => b.deviceUid == state.selectedDeviceUid)
.isNotEmpty;
state = state.copyWith(
bindings: merged,
onlineUids: onlineUids,
deviceRefreshing: false,
selectedDeviceUid: stillSelected ? state.selectedDeviceUid : null,
);
} on Object catch (e) {
debugPrint(e.toString());
state = state.copyWith(
deviceRefreshing: false,
deviceError: '加载绑定设备失败:$e',
);
}
}
/// 在绑定设备列表中选择一个目标设备(仅记录选择,不下发连接)。
void selectDevice(String uid) {
if (state.selectedDeviceUid == uid) return;
state = state.copyWith(selectedDeviceUid: uid);
}
/// 当前选中的绑定设备(无则返回 null)。
BindingDevice? get selectedDevice {
final uid = state.selectedDeviceUid;
if (uid == null) return null;
for (final b in state.bindings) {
if (b.deviceUid == uid) return b;
}
return null;
}
/// 向选中的绑定设备发起远程控制。
///
/// 鉴权方式取自 [BindingDevice.authType],凭据由 UI 的鉴权对话框收集后传入。
Future<void> connectSelectedDevice({
required String authType,
required String authValue,
}) async {
final target = selectedDevice;
if (target == null) {
_alert('请先选择一个绑定的设备');
return;
}
if (!target.online) {
_alert('设备「${target.name}」当前不在线,无法连接');
return;
}
await startControl(
targetDeviceId: target.deviceUid,
authType: authType,
authValue: authValue,
);
}
/// 使用被控端展示的配对码建立绑定关系,并刷新绑定设备列表。
Future<bool> redeemPairingCode(String code) async {
final trimmed = code.trim();
if (trimmed.isEmpty) {
state = state.copyWith(redeemError: '请输入配对码');
return false;
}
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(redeeming: true, redeemError: null);
try {
await authRepository.redeemPairingCode(trimmed);
state = state.copyWith(redeeming: false);
await loadDevices();
return true;
} on Object catch (e) {
state = state.copyWith(redeeming: false, redeemError: '绑定失败:$e');
return false;
}
}
/// 清除配对码兑换错误提示。
void clearRedeemError() => state.copyWith(redeemError: null);
void _wireRemoteCallbacks() {
final remote = _remote!;
remote.onStatusChanged = _setStatus;
remote.onRegistered = (myDeviceId) {
state = state.copyWith(
signalingConnected: true,
connecting: false,
myDeviceId: myDeviceId,
);
};
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,
signalingConnected: false,
myDeviceId: null,
);
_setStatus('状态: 远端已断开');
};
// 以下三种情况仅结束远程控制,保留信令连接以便重新发起。
remote.onIceDisconnected = (message) {
_alert(message);
stopControl();
};
remote.onTargetOffline = (message) {
_alert(message);
stopControl();
};
remote.onConnectionRejected = (message) {
_alert(message);
stopControl();
};
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);
}
/// 结束远程控制但保持信令连接,便于重新选择设备发起连接。
Future<void> stopControl() async {
await _videoRecorder.dispose();
await _remote?.stopControl();
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
final signalingAlive = state.signalingConnected;
state = ConnectionSessionState(
status: signalingAlive ? '状态: 已连接信令服务器' : '状态: 已停止',
signalingConnected: signalingAlive,
myDeviceId: state.myDeviceId,
alert: state.alert,
);
}
/// 断开全部连接(含信令)并复位 UI 状态。
Future<void> disconnect() async {
await _videoRecorder.dispose();
await _remote?.disconnect();
_remote = null;
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
state = ConnectionSessionState(status: '状态: 已停止', alert: state.alert);
}
/// 消费一次性提示消息(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'83ae35ab7dbb3a7d23106106040e9146f1dc6782';
/// 连接控制会话控制器:编排信令 + 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,370 @@
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).stopControl();
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,389 @@
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/binding_device.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
import '../widgets/auth_dialog.dart';
/// 连接设置页:服务器地址 / 绑定设备选择 / 登录 / 连接。
class SetupPage extends ConsumerStatefulWidget {
const SetupPage({super.key});
@override
ConsumerState<SetupPage> createState() => _SetupPageState();
}
class _SetupPageState extends ConsumerState<SetupPage> {
final _deviceIdController = TextEditingController();
@override
void initState() {
super.initState();
// build() 期间不能改动 provider 状态,推迟到首帧之后。
WidgetsBinding.instance.addPostFrameCallback((_) => _autoConnectSignaling());
}
@override
void dispose() {
_deviceIdController.dispose();
super.dispose();
}
/// 进入页面后自动建立信令(WebSocket)连接,并拉取绑定设备列表。
///
/// 仅连接信令并完成注册,**不会**向任何设备发起远程控制;
/// 能进入本页即已登录,携带 Bearer token 即可建连。
Future<void> _autoConnectSignaling() async {
if (!mounted) return;
final loggedIn =
ref.read(authControllerProvider).valueOrNull?.loggedIn ?? false;
if (!loggedIn) return;
await ref
.read(connectionControllerProvider.notifier)
.connectSignaling(serverUrl: kDefaultSignalServer);
if (!mounted) return;
await ref.read(connectionControllerProvider.notifier).loadDevices();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState = ref.watch(authControllerProvider).valueOrNull;
final connection = ref.watch(connectionControllerProvider);
// 登录完成后补发信令连接;退出登录则断开信令。
ref.listen(authControllerProvider, (prev, next) {
final was = prev?.valueOrNull?.loggedIn ?? false;
final now = next.valueOrNull?.loggedIn ?? false;
if (!was && now) {
_autoConnectSignaling();
} else if (was && !now) {
ref.read(connectionControllerProvider.notifier).disconnect();
}
});
// 信令注册成功后回显服务端下发的本机设备ID。
final myDeviceId = connection.myDeviceId ?? '';
if (_deviceIdController.text != myDeviceId) {
_deviceIdController.text = myDeviceId;
}
// 连接建立后跳转控制页;连接失败等一次性提示展示后立即消费。
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if ((prev?.connected ?? false) == false && next.connected) {
context.go('/control');
return;
}
final alert = next.alert;
if (alert != null && alert.isNotEmpty) {
ref.read(connectionControllerProvider.notifier).consumeAlert();
_showAlert(alert);
}
},
);
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.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: 24),
_buildDeviceSection(context, l10n, connection),
const SizedBox(height: 16),
Text(
connection.status,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (connection.deviceError != null) ...[
const SizedBox(height: 8),
Text(
connection.deviceError!,
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
),
],
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,
),
),
),
],
),
),
),
);
}
/// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。
Widget _buildDeviceSection(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.boundDevices, style: const TextStyle(fontSize: 16)),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.deviceRefreshing
? null
: () => ref
.read(connectionControllerProvider.notifier)
.loadDevices(),
child: connection.deviceRefreshing
? const CupertinoActivityIndicator(radius: 9)
: Text(l10n.refreshDevices),
),
],
),
const SizedBox(height: 8),
if (connection.bindings.isEmpty)
_buildEmptyHint(context, l10n, connection)
else
_DeviceList(
devices: connection.bindings,
selectedUid: connection.selectedDeviceUid,
onSelect: (uid) => ref
.read(connectionControllerProvider.notifier)
.selectDevice(uid),
),
const SizedBox(height: 12),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.redeeming
? null
: () => _showRedeemDialog(context, l10n),
child: connection.redeeming
? const CupertinoActivityIndicator()
: Text(l10n.redeemPairingCode),
),
],
);
}
/// 无绑定设备时的引导提示(含配对失败提示)。
Widget _buildEmptyHint(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.noBoundDevices,
style: const TextStyle(fontSize: 14, color: CupertinoColors.systemGrey),
),
if (connection.redeemError != null) ...[
const SizedBox(height: 8),
Text(
connection.redeemError!,
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
),
],
],
);
}
/// 兑换配对码对话框。
Future<void> _showRedeemDialog(
BuildContext context,
AppLocalizations l10n,
) async {
final controller = TextEditingController();
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text(l10n.redeemPairingCode),
content: Column(
children: [
const SizedBox(height: 12),
CupertinoTextField(
controller: controller,
placeholder: l10n.pairingCodeHint,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(ctx).pop(false),
),
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(ctx).pop(true),
),
],
),
);
if (ok != true || !mounted) {
controller.dispose();
return;
}
final code = controller.text.trim();
// 延迟到下一帧再释放,避免对话框卸载时焦点回调访问已释放的 controller。
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (code.isEmpty) return;
final success = await ref
.read(connectionControllerProvider.notifier)
.redeemPairingCode(code);
if (success && mounted) {
_showAlert(l10n.bindSuccess);
} else if (mounted) {
final err = ref.read(connectionControllerProvider).redeemError;
if (err != null && err.isNotEmpty) _showAlert(err);
}
}
Future<void> _onConnectPressed() async {
final l10n = AppLocalizations.of(context);
final selected =
ref.read(connectionControllerProvider.notifier).selectedDevice;
if (selected == null) {
_showAlert(l10n.pleaseSelectDevice);
return;
}
if (!selected.online) {
_showAlert(l10n.deviceOfflineCannotConnect(selected.name ?? selected.deviceUid));
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;
}
// 信令若尚未就绪(如刚登录或曾断开),先补建连接。
if (!ref.read(connectionControllerProvider).signalingConnected) {
await _autoConnectSignaling();
if (!mounted) return;
}
final selection = await showAuthDialog(context);
if (selection == null || !mounted) return;
await ref.read(connectionControllerProvider.notifier).connectSelectedDevice(
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(),
),
],
),
);
}
}
/// 绑定设备选择列表(Cupertino 风格)。
class _DeviceList extends StatelessWidget {
const _DeviceList({
required this.devices,
required this.selectedUid,
required this.onSelect,
});
final List<BindingDevice> devices;
final String? selectedUid;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CupertinoListSection.insetGrouped(
margin: EdgeInsets.zero,
children: [
for (final d in devices)
CupertinoListTile(
title: Text(d.name ?? d.deviceUid),
subtitle: Text(d.online ? l10n.online : l10n.offline),
trailing: selectedUid == d.deviceUid
? const Icon(
CupertinoIcons.check_mark_circled_solid,
color: CupertinoColors.activeBlue,
)
: null,
onTap: () => onSelect(d.deviceUid),
),
],
);
}
}

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,81 @@
{
"@@locale": "en",
"appTitle": "WebRTC Controller",
"login": "Login",
"logout": "Logout",
"registerAccount": "Create Account",
"registerSuccess": "Registered successfully, please log in",
"registerFailed": "Registration failed: {error}",
"loginSubtitle": "Log in to connect your bound devices",
"cancel": "Cancel",
"confirm": "OK",
"connect": "Connect",
"connecting": "Connecting...",
"loginAccount": "Login Account",
"connectDevice": "Connect Device",
"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",
"targetRequired": "Please enter 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.",
"boundDevices": "Bound Devices",
"online": "Online",
"offline": "Offline",
"noBoundDevices": "No bound devices yet",
"refreshDevices": "Refresh",
"redeemPairingCode": "Bind with Pairing Code",
"pairingCodeHint": "Enter the pairing code shown on the controlled device",
"bindingFailed": "Binding failed: {error}",
"deviceOfflineCannotConnect": "Device \"{name}\" is currently offline and cannot be connected",
"pleaseSelectDevice": "Please select a bound device first",
"bindSuccess": "Bound successfully"
}

View File

@@ -0,0 +1,596 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh'),
];
/// No description provided for @appTitle.
///
/// In zh, this message translates to:
/// **'WebRTC 控制端'**
String get appTitle;
/// No description provided for @login.
///
/// In zh, this message translates to:
/// **'登录'**
String get login;
/// No description provided for @logout.
///
/// In zh, this message translates to:
/// **'退出登录'**
String get logout;
/// No description provided for @registerAccount.
///
/// In zh, this message translates to:
/// **'注册新账号'**
String get registerAccount;
/// No description provided for @registerSuccess.
///
/// In zh, this message translates to:
/// **'注册成功,请登录'**
String get registerSuccess;
/// No description provided for @registerFailed.
///
/// In zh, this message translates to:
/// **'注册失败:{error}'**
String registerFailed(Object error);
/// No description provided for @loginSubtitle.
///
/// In zh, this message translates to:
/// **'登录后可连接已绑定的被控设备'**
String get loginSubtitle;
/// 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 @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 @targetRequired.
///
/// In zh, this message translates to:
/// **'请填写目标设备ID'**
String get targetRequired;
/// 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;
/// No description provided for @boundDevices.
///
/// In zh, this message translates to:
/// **'已绑定设备'**
String get boundDevices;
/// No description provided for @online.
///
/// In zh, this message translates to:
/// **'在线'**
String get online;
/// No description provided for @offline.
///
/// In zh, this message translates to:
/// **'离线'**
String get offline;
/// No description provided for @noBoundDevices.
///
/// In zh, this message translates to:
/// **'暂无已绑定设备'**
String get noBoundDevices;
/// No description provided for @refreshDevices.
///
/// In zh, this message translates to:
/// **'刷新'**
String get refreshDevices;
/// No description provided for @redeemPairingCode.
///
/// In zh, this message translates to:
/// **'使用配对码绑定'**
String get redeemPairingCode;
/// No description provided for @pairingCodeHint.
///
/// In zh, this message translates to:
/// **'请输入被控端显示的配对码'**
String get pairingCodeHint;
/// No description provided for @bindingFailed.
///
/// In zh, this message translates to:
/// **'绑定失败:{error}'**
String bindingFailed(Object error);
/// No description provided for @deviceOfflineCannotConnect.
///
/// In zh, this message translates to:
/// **'设备「{name}」当前不在线,无法连接'**
String deviceOfflineCannotConnect(Object name);
/// No description provided for @pleaseSelectDevice.
///
/// In zh, this message translates to:
/// **'请先选择一个绑定的设备'**
String get pleaseSelectDevice;
/// No description provided for @bindSuccess.
///
/// In zh, this message translates to:
/// **'绑定成功'**
String get bindSuccess;
}
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,273 @@
// 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 registerAccount => 'Create Account';
@override
String get registerSuccess => 'Registered successfully, please log in';
@override
String registerFailed(Object error) {
return 'Registration failed: $error';
}
@override
String get loginSubtitle => 'Log in to connect your bound devices';
@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 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 targetRequired => 'Please enter 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.';
@override
String get boundDevices => 'Bound Devices';
@override
String get online => 'Online';
@override
String get offline => 'Offline';
@override
String get noBoundDevices => 'No bound devices yet';
@override
String get refreshDevices => 'Refresh';
@override
String get redeemPairingCode => 'Bind with Pairing Code';
@override
String get pairingCodeHint =>
'Enter the pairing code shown on the controlled device';
@override
String bindingFailed(Object error) {
return 'Binding failed: $error';
}
@override
String deviceOfflineCannotConnect(Object name) {
return 'Device \"$name\" is currently offline and cannot be connected';
}
@override
String get pleaseSelectDevice => 'Please select a bound device first';
@override
String get bindSuccess => 'Bound successfully';
}

View File

@@ -0,0 +1,265 @@
// 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 registerAccount => '注册新账号';
@override
String get registerSuccess => '注册成功,请登录';
@override
String registerFailed(Object error) {
return '注册失败:$error';
}
@override
String get loginSubtitle => '登录后可连接已绑定的被控设备';
@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 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 targetRequired => '请填写目标设备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 => '当前平台不支持自编码硬解。';
@override
String get boundDevices => '已绑定设备';
@override
String get online => '在线';
@override
String get offline => '离线';
@override
String get noBoundDevices => '暂无已绑定设备';
@override
String get refreshDevices => '刷新';
@override
String get redeemPairingCode => '使用配对码绑定';
@override
String get pairingCodeHint => '请输入被控端显示的配对码';
@override
String bindingFailed(Object error) {
return '绑定失败:$error';
}
@override
String deviceOfflineCannotConnect(Object name) {
return '设备「$name」当前不在线,无法连接';
}
@override
String get pleaseSelectDevice => '请先选择一个绑定的设备';
@override
String get bindSuccess => '绑定成功';
}

View File

@@ -0,0 +1,81 @@
{
"@@locale": "zh",
"appTitle": "WebRTC 控制端",
"login": "登录",
"logout": "退出登录",
"registerAccount": "注册新账号",
"registerSuccess": "注册成功,请登录",
"registerFailed": "注册失败:{error}",
"loginSubtitle": "登录后可连接已绑定的被控设备",
"cancel": "取消",
"confirm": "确定",
"connect": "连接",
"connecting": "正在连接...",
"loginAccount": "登录账号",
"connectDevice": "连接被控设备",
"deviceId": "本机设备ID(连接后由服务端下发,无需填写):",
"deviceIdPlaceholder": "连接后由服务端下发",
"targetDeviceId": "目标被控设备ID:",
"targetDeviceIdPlaceholder": "被控端设备ID",
"username": "用户名",
"password": "密码",
"loginFailed": "登录失败:{error}",
"usernamePasswordRequired": "请输入用户名和密码",
"targetRequired": "请填写目标设备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": "当前平台不支持自编码硬解。",
"boundDevices": "已绑定设备",
"online": "在线",
"offline": "离线",
"noBoundDevices": "暂无已绑定设备",
"refreshDevices": "刷新",
"redeemPairingCode": "使用配对码绑定",
"pairingCodeHint": "请输入被控端显示的配对码",
"bindingFailed": "绑定失败:{error}",
"deviceOfflineCannotConnect": "设备「{name}」当前不在线,无法连接",
"pleaseSelectDevice": "请先选择一个绑定的设备",
"bindSuccess": "绑定成功"
}

View File

@@ -1,900 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'config/ice_servers.dart';
import 'controller/remote_controller.dart';
import 'utils/control_commands.dart';
import 'utils/device_utils.dart';
import 'webrtc/self_codec_decoder.dart';
import 'webrtc/video_recorder.dart';
import 'widgets/remote_touch_view.dart';
import 'app/app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return CupertinoApp(
title: 'WebRTC 控制端',
theme: const CupertinoThemeData(primaryColor: CupertinoColors.activeBlue),
home: const ControllerHome(),
);
}
}
class ControllerHome extends StatefulWidget {
const ControllerHome({super.key});
@override
State<ControllerHome> createState() => _ControllerHomeState();
}
class _ControllerHomeState extends State<ControllerHome> {
/// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。
static const MethodChannel _windowChannel = MethodChannel('app/window');
/// 记录上一次已应用的视频宽高比,避免重复调整窗口。
double _lastResizedAspect = 0;
final _serverUrlController = TextEditingController(
text: kDefaultSignalServer,
WidgetsFlutterBinding.ensureInitialized();
// 全局关闭键盘焦点高亮边框
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
runApp(
const ProviderScope(
child: WebrtcControllerApp(),
),
);
final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
RemoteController? _controller;
RTCVideoRenderer? _renderer;
bool _connected = false;
/// 是否正在连接信令服务器 / 建立 WebRTC(连接过程中禁用“连接”按钮)。
bool _connecting = false;
String _status = '状态: 已停止';
String _stats = '';
double _videoAspect = 16 / 9;
/// 当前串流模式:0=WebRTC 全托管;1=自编码(自建 MediaCodec 解码)。
int _streamMode = SelfCodecDecoder.streamModeWebRtc;
int? _selfCodecTextureId;
bool _selfCodecReady = false;
bool _selfCodecSupported = true;
/// 远程视频录制器(基于 flutter_webrtc 的 MediaRecorder)。
final VideoRecorder _videoRecorder = VideoRecorder();
bool _recording = false;
String _recordStatus = '';
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式、待就绪后再开始录制。
bool _pendingRecordStart = false;
/// 分辨率预设:width 为长边像素;0 表示被控端原生分辨率;
/// height 传 0(由被控端按屏幕宽高比计算),fps 传 0(沿用当前帧率)。
int _selectedResolution = 0;
final List<Map<String, Object>> _resolutionOptions = const [
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
];
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
List<int> _fpsOptions = const [15, 24, 30, 60];
/// 被控端当前采集帧率(0 表示尚未收到上报)。
int _currentFps = 0;
/// 被控端最近上报的实际采集尺寸(仅切帧率时保持分辨率不变,0 表示原生)。
int _lastReportedWidth = 0;
int _lastReportedHeight = 0;
@override
void initState() {
super.initState();
_initDeviceId();
}
/// 异步获取设备标识并填入设备ID输入框(非系统签名,使用兜底方案)。
Future<void> _initDeviceId() async {
final id = await DeviceUtils.getSerialNumber();
if (mounted) {
setState(() => _deviceIdController.text = id);
}
}
@override
void dispose() {
_videoRecorder.dispose();
_controller?.disconnect();
_serverUrlController.dispose();
_deviceIdController.dispose();
_targetController.dispose();
super.dispose();
}
void _setStatus(String status) => setState(() => _status = status);
/// 以 iOS 风格弹窗提示用户(替代原 Material 的 SnackBar)。
void _showAlert(String message) {
if (!mounted) return;
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
Future<void> _showAuthDialog() async {
final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
_setStatus('请填写所有字段');
return;
}
String selectedType = 'NONE';
final valueController = TextEditingController();
// 纵向选项列表,避免分段控件在窄弹窗中把中文选项挤压成两行。
Widget buildOption(
String value,
String title,
String desc,
void Function(void Function()) setDialogState,
) {
final selected = selectedType == value;
return GestureDetector(
onTap: () => setDialogState(() => selectedType = value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
border: Border.all(
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey4,
),
borderRadius: BorderRadius.circular(10),
color: selected
? CupertinoColors.activeBlue.withValues(alpha: 0.06)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
desc,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
selected
? CupertinoIcons.check_mark_circled_solid
: CupertinoIcons.circle,
color: selected
? CupertinoColors.activeBlue
: CupertinoColors.systemGrey,
),
],
),
),
);
}
await showCupertinoDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => CupertinoAlertDialog(
title: const Text('连接鉴权'),
content: Column(
children: [
const SizedBox(height: 12),
buildOption(
'NONE',
'免密连接',
'被控端弹出手动确认框,无需验证码或密码',
setDialogState,
),
buildOption(
'CODE',
'动态验证码',
'使用一次性动态验证码鉴权',
setDialogState,
),
buildOption(
'PASSWORD',
'固定密码',
'使用固定连接密码鉴权',
setDialogState,
),
const SizedBox(height: 4),
CupertinoTextField(
controller: valueController,
enabled: selectedType != 'NONE',
placeholder: selectedType == 'NONE'
? '免密连接,无需填写'
: selectedType == 'PASSWORD'
? '请输入固定密码'
: '请输入动态验证码',
obscureText: true,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.of(ctx).pop(),
),
CupertinoDialogAction(
child: const Text('连接'),
onPressed: () {
final val = valueController.text.trim();
if (selectedType != 'NONE' && val.isEmpty) {
_showAlert('请输入验证码或密码');
return;
}
Navigator.of(ctx).pop();
_connect(
authType: selectedType,
authValue: selectedType == 'NONE' ? '' : val,
);
},
),
],
),
),
);
}
Future<void> _connect({required String authType, required String authValue}) async {
final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
_setStatus('请填写所有字段');
return;
}
setState(() => _connecting = true);
_setStatus('状态: 正在连接信令服务器...');
_controller = RemoteController(
serverUrl: serverUrl,
deviceId: deviceId,
targetDeviceId: target,
authType: authType,
authValue: authValue,
);
_controller!.onStatusChanged = _setStatus;
_controller!.onConnectionEstablished = () {
setState(() {
_connected = true;
_connecting = false;
_status = '状态: 已连接 - 远程控制中';
});
};
_controller!.onConnectionFailed = (error) {
if (mounted) setState(() => _connecting = false);
debugPrint('[WebRTC] onConnectionFailed: $error');
_showAlert('连接失败:$error');
};
_controller!.onDisconnected = () {
setState(() {
_connected = false;
_connecting = false;
});
_setStatus('状态: 远端已断开');
};
_controller!.onIceDisconnected = (message) {
_onIceDisconnected(message);
};
_controller!.onTargetOffline = (message) {
_onTargetOffline(message);
};
_controller!.onConnectionRejected = (message) {
_onConnectionRejected(message);
};
_controller!.onRemoteStream = (renderer) {
setState(() => _renderer = renderer);
renderer.addListener(_onRendererUpdate);
if (_pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onStats = (stats) => setState(() => _stats = stats);
_controller!.onSelfCodecReady = (textureId) {
if (mounted) {
setState(() {
_selfCodecTextureId = textureId;
_selfCodecReady = true;
});
}
};
_controller!.onSelfCodecLost = () {
if (mounted) {
setState(() {
_selfCodecReady = false;
_selfCodecTextureId = null;
});
}
};
_controller!.onStreamModeReport = (mode) {
if (mounted) setState(() => _streamMode = mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && _pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onResolutionReported = (w, h) {
if (w > 0 && h > 0 && mounted) {
final aspect = w / h;
setState(() => _videoAspect = aspect);
_resizeWindowToAspect(aspect);
}
};
// 被控端上报当前帧率与支持的帧率档位 -> 同步帧率菜单
_controller!.onFpsReport = (w, h, fps, supportedFps) {
if (!mounted) return;
setState(() {
if (w > 0) _lastReportedWidth = w;
if (h > 0) _lastReportedHeight = h;
if (fps > 0) _currentFps = fps;
if (supportedFps.isNotEmpty) _fpsOptions = supportedFps;
});
};
_controller!.onSelfCodecNotSupported = () {
if (mounted) setState(() => _selfCodecSupported = false);
_showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
};
_controller!.connect(authType: authType, authValue: authValue);
}
void _onRendererUpdate() {
final w = _renderer?.value.width ?? 0;
final h = _renderer?.value.height ?? 0;
if (w > 0 && h > 0 && mounted) {
final aspect = w / h;
setState(() => _videoAspect = aspect);
_resizeWindowToAspect(aspect);
}
}
/// Windows 端:保持窗口宽度不变,按视频宽高比调整窗口高度;
/// 若高度超出屏幕,由原生端改为合适的窗口大小。
void _resizeWindowToAspect(double aspect) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.windows) return;
if (aspect <= 0) return;
if ((aspect - _lastResizedAspect).abs() < 0.001) return;
_lastResizedAspect = aspect;
_windowChannel.invokeMethod('resizeToVideoAspect', aspect);
}
/// ICE 断开:提示用户并自动返回连接设置面板。
Future<void> _onIceDisconnected(String message) async {
_showAlert(message);
await _disconnect();
}
/// 目标被控端不在线:提示用户并复位到连接设置面板。
Future<void> _onTargetOffline(String message) async {
_showAlert(message);
setState(() {
_connected = false;
_connecting = false;
});
}
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
Future<void> _onConnectionRejected(String message) async {
_showAlert(message);
setState(() {
_connected = false;
_connecting = false;
});
}
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
void _showResolutionMenu() {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换分辨率'),
actions: [
for (int i = 0; i < _resolutionOptions.length; i++)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
setState(() => _selectedResolution = i);
final o = _resolutionOptions[i];
_controller?.sendResolutionChange(
o['width'] as int,
o['height'] as int,
o['fps'] as int,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == _selectedResolution) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text(_resolutionOptions[i]['label'] as String),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。
void _showFpsMenu() {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in _fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
if (fps <= 0 || fps == _currentFps) return;
// 分辨率沿用被控端最近上报的实际采集尺寸(0 表示原生)
_controller?.sendResolutionChange(
_lastReportedWidth,
_lastReportedHeight,
fps,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == _currentFps) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text('${fps}fps'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 切换串流模式:WebRTC 全托管 <-> 自编码(自建 MediaCodec 解码)。
void _toggleStreamMode() {
if (!_selfCodecSupported) {
_showAlert('当前平台不支持自编码硬解。');
return;
}
final next = _streamMode == SelfCodecDecoder.streamModeSelfCodec
? SelfCodecDecoder.streamModeWebRtc
: SelfCodecDecoder.streamModeSelfCodec;
setState(() => _streamMode = next);
_controller?.sendStreamMode(next);
}
/// 切换远程视频录制:开始 / 停止。
Future<void> _toggleRecord() async {
if (_recording) {
await _stopRecording();
return;
}
if (_renderer == null) {
_showAlert('尚未连接或没有视频画面,无法录制');
return;
}
if (_streamMode == SelfCodecDecoder.streamModeSelfCodec) {
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
_pendingRecordStart = true;
setState(() => _recordStatus = '正在切回标准模式以开始录制...');
_controller?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
return;
}
await _startRecording();
}
/// 开始录制当前远端视频轨道。
Future<void> _startRecording() async {
if (_recording || !mounted) return;
final stream = _renderer?.srcObject;
if (stream == null) {
_showAlert('尚未接收到视频画面,无法录制');
return;
}
try {
final ok = await _videoRecorder.start(stream);
if (ok && mounted) {
setState(() {
_recording = true;
_recordStatus = '录制中...';
});
}
} catch (e) {
if (mounted) {
setState(() => _recordStatus = '');
debugPrint('_startRecording error: $e');
_showAlert('开始录制失败:$e');
}
}
}
/// 停止录制并提示保存位置。
Future<void> _stopRecording() async {
final path = await _videoRecorder.stop();
if (mounted) {
setState(() {
_recording = false;
_recordStatus = path != null ? '已保存:$path' : '录制已停止';
});
}
}
Future<void> _disconnect() async {
await _videoRecorder.dispose();
await _controller?.disconnect();
_controller = null;
_renderer?.removeListener(_onRendererUpdate);
_lastResizedAspect = 0;
setState(() {
_connected = false;
_connecting = false;
_renderer = null;
_status = '状态: 已停止';
_stats = '';
_streamMode = SelfCodecDecoder.streamModeWebRtc;
_selfCodecTextureId = null;
_selfCodecReady = false;
_selfCodecSupported = true;
_recording = false;
_recordStatus = '';
_pendingRecordStart = false;
_currentFps = 0;
_lastReportedWidth = 0;
_lastReportedHeight = 0;
_fpsOptions = const [15, 24, 30, 60];
});
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
// 控制模式(已连接)下隐藏导航栏,避免其半透明浮层遮挡顶部的
// 状态/统计信息浮层;连接设置面板再显示标题栏。
navigationBar: _connected
? null
: const CupertinoNavigationBar(
middle: Text('WebRTC 控制端'),
),
child: _connected ? _buildControlPanel() : _buildSetupPanel(),
);
}
Widget _buildSetupPanel() {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'WebRTC 控制端',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
const Text('信令服务器地址:', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _serverUrlController,
placeholder: 'wss://www.ttstd.com/signal',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 16),
const Text('本机设备ID:', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _deviceIdController,
placeholder: '设备ID',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 16),
const Text('目标被控设备ID:', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: '被控端设备ID',
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
),
const SizedBox(height: 24),
Text(
_status,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _connecting ? null : _showAuthDialog,
child: Text(_connecting ? '正在连接...' : '连接被控设备'),
),
),
],
),
),
);
}
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
Widget _buildTopMenuBar() {
return Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(22),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 分辨率菜单按钮:显示当前分辨率标签
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _showResolutionMenu,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.slider_horizontal_3,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
_resolutionOptions[_selectedResolution]['label'] as String,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 帧率菜单按钮:显示被控端当前采集帧率
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _showFpsMenu,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.speedometer,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
_currentFps > 0 ? '${_currentFps}fps' : '帧率',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _selfCodecSupported ? _toggleStreamMode : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'自编码',
style: TextStyle(
color: _selfCodecSupported
? CupertinoColors.white
: CupertinoColors.white.withValues(alpha: 0.4),
fontSize: 14,
),
),
const SizedBox(width: 6),
CupertinoSwitch(
value: _streamMode == SelfCodecDecoder.streamModeSelfCodec,
onChanged: _selfCodecSupported
? (_) => _toggleStreamMode()
: null,
activeTrackColor: CupertinoColors.activeBlue,
),
],
),
),
// 远程视频录制开关
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _toggleRecord,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_recording
? CupertinoIcons.stop_circle
: CupertinoIcons.video_camera,
color: _recording
? CupertinoColors.destructiveRed
: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
_recording ? '停止' : '录制',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 分隔线
Container(
width: 1,
height: 22,
color: CupertinoColors.white.withValues(alpha: 0.3),
),
// 断开连接按钮
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _disconnect,
child: const Icon(
CupertinoIcons.xmark_circle_fill,
color: CupertinoColors.destructiveRed,
size: 24,
),
),
],
),
);
}
Widget _buildControlPanel() {
return SafeArea(
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
// 以便右下角的断开按钮贴近屏幕边缘。
top: true,
bottom: false,
left: false,
right: false,
child: Stack(
children: [
Container(color: Colors.black),
if (_streamMode == SelfCodecDecoder.streamModeSelfCodec &&
_selfCodecReady &&
_selfCodecTextureId != null)
Center(
child: AspectRatio(
aspectRatio: _videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
Texture(textureId: _selfCodecTextureId!),
RemoteTouchView(
onTouch: (x, y) {},
onSwipe: (x1, y1, x2, y2, d) {},
onLongPress: (x, y) {},
onKey: (k, a) =>
_controller?.sendControlCommand(ControlCommands.key(k, a)),
onMotionEvent: (a, x, y) => _controller?.sendControlCommand(
ControlCommands.motionEvent(a, x, y),
),
),
],
),
),
)
else if (_renderer != null)
Center(
child: AspectRatio(
aspectRatio: _videoAspect,
child: Stack(
fit: StackFit.expand,
children: [
RTCVideoView(
_renderer!,
objectFit:
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
RemoteTouchView(
// 禁用离散的 TOUCH/SWIPE/LONG_PRESS 指令,改用实时的 onMotionEvent 以解决重复操作问题。
// 原始的动作流已包含完整的触摸过程,被控端系统会自动识别单击、滑动和长按。
onTouch: (x, y) {},
onSwipe: (x1, y1, x2, y2, d) {},
onLongPress: (x, y) {},
onKey: (k, a) => _controller?.sendControlCommand(
ControlCommands.key(k, a),
),
onMotionEvent: (a, x, y) => _controller?.sendControlCommand(
ControlCommands.motionEvent(a, x, y),
),
),
],
),
),
),
Positioned(
top: 0,
left: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_status, style: const TextStyle(color: Colors.white)),
Text(
_stats,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
if (_recordStatus.isNotEmpty)
Text(
_recordStatus,
style: const TextStyle(
color: CupertinoColors.systemOrange,
fontSize: 12,
),
),
],
),
),
),
Positioned(
top: 8,
right: 8,
child: _buildTopMenuBar(),
),
],
),
);
}
}

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

@@ -1,88 +0,0 @@
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/signal_message.dart';
/// 信令客户端,封装 WebSocket 连接与消息收发。
///
/// 连接成功后自动向服务器发送 REGISTER 注册为 CONTROLLER 设备,
/// 与 Android 端 WebSocketClient 行为一致。
class SignalingClient {
final String serverUrl;
final String deviceId;
WebSocketChannel? _channel;
/// 收到信令消息回调(已解析为 SignalMessage)。
void Function(SignalMessage message)? onMessage;
/// 连接成功回调。
void Function()? onConnected;
/// 连接断开回调。
void Function()? onDisconnected;
/// 连接错误回调。
void Function(String error)? onError;
SignalingClient({required this.serverUrl, required this.deviceId});
/// 建立 WebSocket 连接并注册设备。
void connect() {
try {
final uri = Uri.parse(serverUrl);
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
_onData,
onDone: () => onDisconnected?.call(),
onError: (Object e) => onError?.call(e.toString()),
cancelOnError: false,
);
// 连接建立后注册设备(与 Android 端 onOpen -> registerDevice 对应)。
_register();
} catch (e) {
onError?.call(e.toString());
}
}
void _onData(dynamic data) {
if (data is! String) return;
try {
final map = jsonDecode(data) as Map<String, dynamic>;
final message = SignalMessage.fromJson(map);
onMessage?.call(message);
} catch (_) {
// 忽略无法解析的消息。
}
}
void _register() {
final msg = SignalMessage(
type: 'REGISTER',
fromDeviceId: deviceId,
deviceType: 'CONTROLLER',
);
_send(msg);
// 通知上层已“连接”(WebSocketChannel 会缓冲发送,直到底层连接就绪);
// 后续 OFFER 等消息会排在 REGISTER 之后发送,保证服务器先完成注册。
onConnected?.call();
}
/// 发送信令消息。
void send(SignalMessage message) => _send(message);
void _send(SignalMessage message) {
_channel?.sink.add(jsonEncode(message.toJson()));
}
/// 关闭连接。
void disconnect() {
_channel?.sink.close();
_channel = null;
}
bool get isConnected => _channel != null;
}

View File

@@ -1,78 +0,0 @@
import 'package:fixnum/fixnum.dart';
import 'package:webrtc_controller_flutter/proto/control_message.pb.dart';
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
///
/// 指令通过 DataChannel 以 protobuf 二进制([ControlMessage])发送,
/// 被控端(WebRTCControlled)的 InputCommandHandler 负责解析执行。
class ControlCommands {
ControlCommands._();
/// 单击指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
static ControlMessage touch(double x, double y) => ControlMessage(
action: Action.TOUCH,
x: x,
y: y,
);
/// 滑动指令。坐标为相对百分比,duration 为毫秒。
static ControlMessage swipe(
double x1,
double y1,
double x2,
double y2,
int durationMs,
) =>
ControlMessage(
action: Action.SWIPE,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
duration: Int64(durationMs),
);
/// 按键指令。action: 0=按下, 1=抬起(对应 Android KeyEvent.ACTION_DOWN/UP)。
static ControlMessage key(int keyCode, int action) => ControlMessage(
action: Action.KEY,
keyCode: keyCode,
keyAction: action,
);
/// 长按指令。坐标 x/y 为相对于屏幕的百分比(0.0 ~ 1.0)。
/// 对应 Android 端 RemoteTouchView.createLongPressCommand。
static ControlMessage longPress(double x, double y) => ControlMessage(
action: Action.LONG_PRESS,
x: x,
y: y,
);
/// 原始 MotionEvent 指令,用于实现“实时跟手”。
/// action: 0=DOWN, 1=UP, 2=MOVE(对应 Android MotionEvent.ACTION_XXX)。
static ControlMessage motionEvent(int action, double x, double y) =>
ControlMessage(
action: Action.MOTION_EVENT,
motionAction: action,
x: x,
y: y,
);
/// 分辨率切换指令。
/// [width] 目标长边/宽度(<=0 表示被控端原生分辨率);
/// [height] 目标高度(<=0 时由被控端按屏幕宽高比计算);
/// [fps] 目标帧率(<=0 表示沿用当前帧率)。
static ControlMessage setResolution(int width, int height, int fps) =>
ControlMessage(
action: Action.SET_RESOLUTION,
width: width,
height: height,
fps: fps,
);
/// 串流模式切换指令。
/// [mode] 为 0=WebRTC 全托管 / 1=自编码(自建 MediaCodec 编解码 + video DataChannel 裸流透传)。
static ControlMessage streamMode(int mode) => ControlMessage(
action: Action.SET_STREAM_MODE,
streamMode: mode,
);
}