docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构
AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
269
webrtc_controller_flutter/lib/core/proto/control_message.pb.dart
Normal file
269
webrtc_controller_flutter/lib/core/proto/control_message.pb.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from control_message.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:fixnum/fixnum.dart' as $fixnum;
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
import 'control_message.pbenum.dart';
|
||||
|
||||
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
|
||||
|
||||
export 'control_message.pbenum.dart';
|
||||
|
||||
/// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||
/// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。
|
||||
class ControlMessage extends $pb.GeneratedMessage {
|
||||
factory ControlMessage({
|
||||
Action? action,
|
||||
$core.double? x,
|
||||
$core.double? y,
|
||||
$core.double? x1,
|
||||
$core.double? y1,
|
||||
$core.double? x2,
|
||||
$core.double? y2,
|
||||
$fixnum.Int64? duration,
|
||||
$core.int? keyCode,
|
||||
$core.int? keyAction,
|
||||
$core.int? motionAction,
|
||||
$core.int? width,
|
||||
$core.int? height,
|
||||
$core.int? fps,
|
||||
$core.int? streamMode,
|
||||
$core.Iterable<$core.int>? supportedFps,
|
||||
}) {
|
||||
final result = create();
|
||||
if (action != null) result.action = action;
|
||||
if (x != null) result.x = x;
|
||||
if (y != null) result.y = y;
|
||||
if (x1 != null) result.x1 = x1;
|
||||
if (y1 != null) result.y1 = y1;
|
||||
if (x2 != null) result.x2 = x2;
|
||||
if (y2 != null) result.y2 = y2;
|
||||
if (duration != null) result.duration = duration;
|
||||
if (keyCode != null) result.keyCode = keyCode;
|
||||
if (keyAction != null) result.keyAction = keyAction;
|
||||
if (motionAction != null) result.motionAction = motionAction;
|
||||
if (width != null) result.width = width;
|
||||
if (height != null) result.height = height;
|
||||
if (fps != null) result.fps = fps;
|
||||
if (streamMode != null) result.streamMode = streamMode;
|
||||
if (supportedFps != null) result.supportedFps.addAll(supportedFps);
|
||||
return result;
|
||||
}
|
||||
|
||||
ControlMessage._();
|
||||
|
||||
factory ControlMessage.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory ControlMessage.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'ControlMessage',
|
||||
package:
|
||||
const $pb.PackageName(_omitMessageNames ? '' : 'com.ttstd.control'),
|
||||
createEmptyInstance: create)
|
||||
..aE<Action>(1, _omitFieldNames ? '' : 'action', enumValues: Action.values)
|
||||
..aD(2, _omitFieldNames ? '' : 'x')
|
||||
..aD(3, _omitFieldNames ? '' : 'y')
|
||||
..aD(4, _omitFieldNames ? '' : 'x1')
|
||||
..aD(5, _omitFieldNames ? '' : 'y1')
|
||||
..aD(6, _omitFieldNames ? '' : 'x2')
|
||||
..aD(7, _omitFieldNames ? '' : 'y2')
|
||||
..aInt64(8, _omitFieldNames ? '' : 'duration')
|
||||
..aI(9, _omitFieldNames ? '' : 'keyCode')
|
||||
..aI(10, _omitFieldNames ? '' : 'keyAction')
|
||||
..aI(11, _omitFieldNames ? '' : 'motionAction')
|
||||
..aI(12, _omitFieldNames ? '' : 'width')
|
||||
..aI(13, _omitFieldNames ? '' : 'height')
|
||||
..aI(14, _omitFieldNames ? '' : 'fps')
|
||||
..aI(15, _omitFieldNames ? '' : 'stream_mode')
|
||||
..p<$core.int>(16, _omitFieldNames ? '' : 'supportedFps', $pb.PbFieldType.K3)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
ControlMessage clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
ControlMessage copyWith(void Function(ControlMessage) updates) =>
|
||||
super.copyWith((message) => updates(message as ControlMessage))
|
||||
as ControlMessage;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ControlMessage create() => ControlMessage._();
|
||||
@$core.override
|
||||
ControlMessage createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ControlMessage getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<ControlMessage>(create);
|
||||
static ControlMessage? _defaultInstance;
|
||||
|
||||
/// 指令类型
|
||||
@$pb.TagNumber(1)
|
||||
Action get action => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set action(Action value) => $_setField(1, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasAction() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearAction() => $_clearField(1);
|
||||
|
||||
/// 单点坐标(TOUCH / LONG_PRESS / MOTION_EVENT)
|
||||
@$pb.TagNumber(2)
|
||||
$core.double get x => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set x($core.double value) => $_setDouble(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasX() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearX() => $_clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.double get y => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set y($core.double value) => $_setDouble(2, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasY() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearY() => $_clearField(3);
|
||||
|
||||
/// 滑动起止坐标(SWIPE)
|
||||
@$pb.TagNumber(4)
|
||||
$core.double get x1 => $_getN(3);
|
||||
@$pb.TagNumber(4)
|
||||
set x1($core.double value) => $_setDouble(3, value);
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasX1() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearX1() => $_clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$core.double get y1 => $_getN(4);
|
||||
@$pb.TagNumber(5)
|
||||
set y1($core.double value) => $_setDouble(4, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasY1() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearY1() => $_clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
$core.double get x2 => $_getN(5);
|
||||
@$pb.TagNumber(6)
|
||||
set x2($core.double value) => $_setDouble(5, value);
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasX2() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearX2() => $_clearField(6);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
$core.double get y2 => $_getN(6);
|
||||
@$pb.TagNumber(7)
|
||||
set y2($core.double value) => $_setDouble(6, value);
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasY2() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearY2() => $_clearField(7);
|
||||
|
||||
@$pb.TagNumber(8)
|
||||
$fixnum.Int64 get duration => $_getI64(7);
|
||||
@$pb.TagNumber(8)
|
||||
set duration($fixnum.Int64 value) => $_setInt64(7, value);
|
||||
@$pb.TagNumber(8)
|
||||
$core.bool hasDuration() => $_has(7);
|
||||
@$pb.TagNumber(8)
|
||||
void clearDuration() => $_clearField(8);
|
||||
|
||||
/// 按键(KEY):key_action 0=按下 1=抬起(对应 Android KeyEvent ACTION_DOWN/UP)
|
||||
@$pb.TagNumber(9)
|
||||
$core.int get keyCode => $_getIZ(8);
|
||||
@$pb.TagNumber(9)
|
||||
set keyCode($core.int value) => $_setSignedInt32(8, value);
|
||||
@$pb.TagNumber(9)
|
||||
$core.bool hasKeyCode() => $_has(8);
|
||||
@$pb.TagNumber(9)
|
||||
void clearKeyCode() => $_clearField(9);
|
||||
|
||||
@$pb.TagNumber(10)
|
||||
$core.int get keyAction => $_getIZ(9);
|
||||
@$pb.TagNumber(10)
|
||||
set keyAction($core.int value) => $_setSignedInt32(9, value);
|
||||
@$pb.TagNumber(10)
|
||||
$core.bool hasKeyAction() => $_has(9);
|
||||
@$pb.TagNumber(10)
|
||||
void clearKeyAction() => $_clearField(10);
|
||||
|
||||
/// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||
@$pb.TagNumber(11)
|
||||
$core.int get motionAction => $_getIZ(10);
|
||||
@$pb.TagNumber(11)
|
||||
set motionAction($core.int value) => $_setSignedInt32(10, value);
|
||||
@$pb.TagNumber(11)
|
||||
$core.bool hasMotionAction() => $_has(10);
|
||||
@$pb.TagNumber(11)
|
||||
void clearMotionAction() => $_clearField(11);
|
||||
|
||||
/// 分辨率切换(SET_RESOLUTION):目标宽度/长边(<=0 表示原生分辨率)
|
||||
@$pb.TagNumber(12)
|
||||
$core.int get width => $_getIZ(11);
|
||||
@$pb.TagNumber(12)
|
||||
set width($core.int value) => $_setSignedInt32(11, value);
|
||||
@$pb.TagNumber(12)
|
||||
$core.bool hasWidth() => $_has(11);
|
||||
@$pb.TagNumber(12)
|
||||
void clearWidth() => $_clearField(12);
|
||||
|
||||
/// 分辨率切换(SET_RESOLUTION):目标高度(<=0 时按屏幕宽高比基于 width 计算)
|
||||
@$pb.TagNumber(13)
|
||||
$core.int get height => $_getIZ(12);
|
||||
@$pb.TagNumber(13)
|
||||
set height($core.int value) => $_setSignedInt32(12, value);
|
||||
@$pb.TagNumber(13)
|
||||
$core.bool hasHeight() => $_has(12);
|
||||
@$pb.TagNumber(13)
|
||||
void clearHeight() => $_clearField(13);
|
||||
|
||||
/// 分辨率切换(SET_RESOLUTION):目标帧率(<=0 表示沿用当前帧率)
|
||||
@$pb.TagNumber(14)
|
||||
$core.int get fps => $_getIZ(13);
|
||||
@$pb.TagNumber(14)
|
||||
set fps($core.int value) => $_setSignedInt32(13, value);
|
||||
@$pb.TagNumber(14)
|
||||
$core.bool hasFps() => $_has(13);
|
||||
@$pb.TagNumber(14)
|
||||
void clearFps() => $_clearField(14);
|
||||
|
||||
/// 串流模式(SET_STREAM_MODE / REPORT_STREAM_MODE):
|
||||
/// 0 = WebRTC 全托管;1 = 自编码(自建 MediaCodec 编解码 + video DataChannel)
|
||||
@$pb.TagNumber(15)
|
||||
$core.int get streamMode => $_getIZ(14);
|
||||
@$pb.TagNumber(15)
|
||||
set streamMode($core.int value) => $_setSignedInt32(14, value);
|
||||
@$pb.TagNumber(15)
|
||||
$core.bool hasStreamMode() => $_has(14);
|
||||
@$pb.TagNumber(15)
|
||||
void clearStreamMode() => $_clearField(15);
|
||||
|
||||
/// 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
|
||||
@$pb.TagNumber(16)
|
||||
$pb.PbList<$core.int> get supportedFps => $_getList(15);
|
||||
}
|
||||
|
||||
const $core.bool _omitFieldNames =
|
||||
$core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const $core.bool _omitMessageNames =
|
||||
$core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
@@ -0,0 +1,59 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from control_message.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
/// 控制指令类型,对应原 JSON 字段 action。
|
||||
class Action extends $pb.ProtobufEnum {
|
||||
static const Action ACTION_UNKNOWN =
|
||||
Action._(0, _omitEnumNames ? '' : 'ACTION_UNKNOWN');
|
||||
static const Action TOUCH = Action._(1, _omitEnumNames ? '' : 'TOUCH');
|
||||
static const Action SWIPE = Action._(2, _omitEnumNames ? '' : 'SWIPE');
|
||||
static const Action KEY = Action._(3, _omitEnumNames ? '' : 'KEY');
|
||||
static const Action LONG_PRESS =
|
||||
Action._(4, _omitEnumNames ? '' : 'LONG_PRESS');
|
||||
static const Action MOTION_EVENT =
|
||||
Action._(5, _omitEnumNames ? '' : 'MOTION_EVENT');
|
||||
static const Action SET_RESOLUTION =
|
||||
Action._(6, _omitEnumNames ? '' : 'SET_RESOLUTION');
|
||||
static const Action REPORT_RESOLUTION =
|
||||
Action._(7, _omitEnumNames ? '' : 'REPORT_RESOLUTION');
|
||||
static const Action SET_STREAM_MODE =
|
||||
Action._(8, _omitEnumNames ? '' : 'SET_STREAM_MODE');
|
||||
static const Action REPORT_STREAM_MODE =
|
||||
Action._(9, _omitEnumNames ? '' : 'REPORT_STREAM_MODE');
|
||||
|
||||
static const $core.List<Action> values = <Action>[
|
||||
ACTION_UNKNOWN,
|
||||
TOUCH,
|
||||
SWIPE,
|
||||
KEY,
|
||||
LONG_PRESS,
|
||||
MOTION_EVENT,
|
||||
SET_RESOLUTION,
|
||||
REPORT_RESOLUTION,
|
||||
SET_STREAM_MODE,
|
||||
REPORT_STREAM_MODE,
|
||||
];
|
||||
|
||||
static final $core.List<Action?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 9);
|
||||
static Action? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const Action._(super.value, super.name);
|
||||
}
|
||||
|
||||
const $core.bool _omitEnumNames =
|
||||
$core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
@@ -0,0 +1,68 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from control_message.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use actionDescriptor instead')
|
||||
const Action$json = {
|
||||
'1': 'Action',
|
||||
'2': [
|
||||
{'1': 'ACTION_UNKNOWN', '2': 0},
|
||||
{'1': 'TOUCH', '2': 1},
|
||||
{'1': 'SWIPE', '2': 2},
|
||||
{'1': 'KEY', '2': 3},
|
||||
{'1': 'LONG_PRESS', '2': 4},
|
||||
{'1': 'MOTION_EVENT', '2': 5},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Action`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List actionDescriptor = $convert.base64Decode(
|
||||
'CgZBY3Rpb24SEgoOQUNUSU9OX1VOS05PV04QABIJCgVUT1VDSBABEgkKBVNXSVBFEAISBwoDS0'
|
||||
'VZEAMSDgoKTE9OR19QUkVTUxAEEhAKDE1PVElPTl9FVkVOVBAF');
|
||||
|
||||
@$core.Deprecated('Use controlMessageDescriptor instead')
|
||||
const ControlMessage$json = {
|
||||
'1': 'ControlMessage',
|
||||
'2': [
|
||||
{
|
||||
'1': 'action',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 14,
|
||||
'6': '.com.ttstd.control.Action',
|
||||
'10': 'action'
|
||||
},
|
||||
{'1': 'x', '3': 2, '4': 1, '5': 1, '10': 'x'},
|
||||
{'1': 'y', '3': 3, '4': 1, '5': 1, '10': 'y'},
|
||||
{'1': 'x1', '3': 4, '4': 1, '5': 1, '10': 'x1'},
|
||||
{'1': 'y1', '3': 5, '4': 1, '5': 1, '10': 'y1'},
|
||||
{'1': 'x2', '3': 6, '4': 1, '5': 1, '10': 'x2'},
|
||||
{'1': 'y2', '3': 7, '4': 1, '5': 1, '10': 'y2'},
|
||||
{'1': 'duration', '3': 8, '4': 1, '5': 3, '10': 'duration'},
|
||||
{'1': 'key_code', '3': 9, '4': 1, '5': 5, '10': 'keyCode'},
|
||||
{'1': 'key_action', '3': 10, '4': 1, '5': 5, '10': 'keyAction'},
|
||||
{'1': 'motion_action', '3': 11, '4': 1, '5': 5, '10': 'motionAction'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ControlMessage`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List controlMessageDescriptor = $convert.base64Decode(
|
||||
'Cg5Db250cm9sTWVzc2FnZRIxCgZhY3Rpb24YASABKA4yGS5jb20udHRzdGQuY29udHJvbC5BY3'
|
||||
'Rpb25SBmFjdGlvbhIMCgF4GAIgASgBUgF4EgwKAXkYAyABKAFSAXkSDgoCeDEYBCABKAFSAngx'
|
||||
'Eg4KAnkxGAUgASgBUgJ5MRIOCgJ4MhgGIAEoAVICeDISDgoCeTIYByABKAFSAnkyEhoKCGR1cm'
|
||||
'F0aW9uGAggASgDUghkdXJhdGlvbhIZCghrZXlfY29kZRgJIAEoBVIHa2V5Q29kZRIdCgprZXlf'
|
||||
'YWN0aW9uGAogASgFUglrZXlBY3Rpb24SIwoNbW90aW9uX2FjdGlvbhgLIAEoBVIMbW90aW9uQW'
|
||||
'N0aW9u');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:webrtc_controller_flutter/core/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,
|
||||
);
|
||||
}
|
||||
51
webrtc_controller_flutter/lib/core/utils/device_utils.dart
Normal file
51
webrtc_controller_flutter/lib/core/utils/device_utils.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:android_id/android_id.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 设备信息工具类(控制端,非系统签名应用)
|
||||
///
|
||||
/// 与 WebRTCControlled 的 DeviceUtils 对应,但控制端没有系统签名,
|
||||
/// 因此无法稳定获取真实的硬件序列号,这里采用「不要求准确」的兜底方案:
|
||||
/// 1. 优先使用官方插件([DeviceInfoPlugin])提供的 serialNumber;
|
||||
/// 2. 如果 serialNumber 为 unknown,则尝试获取 Android ID;
|
||||
/// 3. 失败则使用 iOS 的 identifierForVendor;
|
||||
/// 4. 再失败则生成随机 UUID。
|
||||
class DeviceUtils {
|
||||
DeviceUtils._();
|
||||
|
||||
/// 获取设备标识(适用于普通应用)。
|
||||
///
|
||||
/// 兼容各平台:Android 优先使用官方序列号,其次使用 Android ID,
|
||||
/// iOS 使用 identifierForVendor,均不可用时回退到随机 UUID。
|
||||
static Future<String> getSerialNumber() async {
|
||||
const uuid = Uuid();
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
final serial = androidInfo.serialNumber;
|
||||
if (serial.isNotEmpty && serial.toLowerCase() != 'unknown') {
|
||||
return serial;
|
||||
}
|
||||
|
||||
// 如果 serialNumber 获取失败,尝试获取 Android ID
|
||||
const androidIdPlugin = AndroidId();
|
||||
final androidId = await androidIdPlugin.getId();
|
||||
if (androidId != null && androidId.isNotEmpty) {
|
||||
return androidId;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
final id = iosInfo.identifierForVendor;
|
||||
if (id != null && id.isNotEmpty) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 获取失败,走兜底逻辑
|
||||
}
|
||||
return uuid.v4();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 远端触摸控制层(对应 Android 端 RemoteTouchView)。
|
||||
///
|
||||
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
|
||||
/// (0.0 ~ 1.0),以便在不同分辨率的设备间换算。
|
||||
///
|
||||
/// - 短时间内位移很小的抬起 -> 单击(TOUCH)
|
||||
/// - 否则 -> 滑动(SWIPE)
|
||||
/// - 按住不动超过阈值 -> 长按(LONG_PRESS)
|
||||
/// 同时捕获物理按键事件(KEY),用于外接键盘 / 遥控器场景。
|
||||
///
|
||||
/// 该控件完全基于 Flutter 框架实现,无需任何原生代码,可运行于
|
||||
/// Android、iOS 等平台。
|
||||
class RemoteTouchView extends StatefulWidget {
|
||||
final void Function(double x, double y) onTouch;
|
||||
final void Function(
|
||||
double x1,
|
||||
double y1,
|
||||
double x2,
|
||||
double y2,
|
||||
int durationMs,
|
||||
) onSwipe;
|
||||
final void Function(int keyCode, int action) onKey;
|
||||
final void Function(double x, double y) onLongPress;
|
||||
final void Function(int action, double x, double y) onMotionEvent;
|
||||
|
||||
const RemoteTouchView({
|
||||
super.key,
|
||||
required this.onTouch,
|
||||
required this.onSwipe,
|
||||
required this.onKey,
|
||||
required this.onLongPress,
|
||||
required this.onMotionEvent,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RemoteTouchView> createState() => _RemoteTouchViewState();
|
||||
}
|
||||
|
||||
class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||||
Offset? _startPosition;
|
||||
Offset? _currentPosition;
|
||||
DateTime? _startTime;
|
||||
bool _isLongPressed = false;
|
||||
Timer? _longPressTimer;
|
||||
|
||||
// 缓存 RenderBox 信息以优化坐标换算开销
|
||||
RenderBox? _cachedRenderBox;
|
||||
Size? _cachedSize;
|
||||
|
||||
// 采样率限制:控制 ACTION_MOVE 的发送频率(例如每 16ms 发送一次,约 60fps)
|
||||
int _lastMoveTimestamp = 0;
|
||||
static const int _sampleIntervalMs = 16;
|
||||
|
||||
/// 长按触发阈值,对应 Android GestureDetector 默认的 LONG_PRESS_TIMEOUT(400ms)。
|
||||
static const _longPressTimeout = Duration(milliseconds: 400);
|
||||
|
||||
/// 手指移动超过该像素阈值则视为滚动,取消长按(对应 Android touch slop)。
|
||||
static const _touchSlop = 10.0;
|
||||
|
||||
double _toRelativeX(Offset local, Size size) =>
|
||||
(local.dx / size.width).clamp(0.0, 1.0);
|
||||
|
||||
double _toRelativeY(Offset local, Size size) =>
|
||||
(local.dy / size.height).clamp(0.0, 1.0);
|
||||
|
||||
void _cancelLongPress() {
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelLongPress();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 0);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event is KeyUpEvent) {
|
||||
widget.onKey(event.logicalKey.keyId, 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_cancelLongPress();
|
||||
_isLongPressed = false;
|
||||
_startPosition = event.localPosition;
|
||||
_currentPosition = event.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
_lastMoveTimestamp = 0; // 重置采样计时
|
||||
|
||||
// 开始触摸时缓存 RenderBox 和 Size
|
||||
_cachedRenderBox = context.findRenderObject() as RenderBox?;
|
||||
_cachedSize = _cachedRenderBox?.size;
|
||||
|
||||
if (_cachedSize != null) {
|
||||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||||
widget.onMotionEvent(0, rx, ry); // ACTION_DOWN
|
||||
}
|
||||
|
||||
_longPressTimer = Timer(_longPressTimeout, () {
|
||||
if (_currentPosition == null || _cachedSize == null) return;
|
||||
_isLongPressed = true;
|
||||
final x = _toRelativeX(_currentPosition!, _cachedSize!);
|
||||
final y = _toRelativeY(_currentPosition!, _cachedSize!);
|
||||
widget.onLongPress(x, y);
|
||||
});
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
_currentPosition = event.localPosition;
|
||||
|
||||
if (_cachedSize != null) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
// 采样率限制逻辑
|
||||
if (now - _lastMoveTimestamp >= _sampleIntervalMs) {
|
||||
_lastMoveTimestamp = now;
|
||||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||||
widget.onMotionEvent(2, rx, ry); // ACTION_MOVE
|
||||
}
|
||||
}
|
||||
|
||||
if (_longPressTimer != null && _startPosition != null) {
|
||||
if ((event.localPosition - _startPosition!).distance > _touchSlop) {
|
||||
_cancelLongPress();
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
_cancelLongPress();
|
||||
|
||||
if (_cachedSize != null) {
|
||||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||||
widget.onMotionEvent(1, rx, ry); // ACTION_UP
|
||||
|
||||
if (!_isLongPressed && _startPosition != null && _startTime != null) {
|
||||
final startX = _toRelativeX(_startPosition!, _cachedSize!);
|
||||
final startY = _toRelativeY(_startPosition!, _cachedSize!);
|
||||
final duration = DateTime.now().difference(_startTime!).inMilliseconds;
|
||||
final dx = (rx - startX).abs();
|
||||
final dy = (ry - startY).abs();
|
||||
|
||||
if (duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||
widget.onTouch(startX, startY);
|
||||
} else {
|
||||
widget.onSwipe(startX, startY, rx, ry, max(duration, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理状态
|
||||
_startPosition = null;
|
||||
_currentPosition = null;
|
||||
_startTime = null;
|
||||
_cachedRenderBox = null;
|
||||
_cachedSize = null;
|
||||
},
|
||||
child: const ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user