docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构
AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/proto/control_message.pb.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/remote_controller.dart';
|
||||
import '../data/self_codec_decoder.dart';
|
||||
import '../data/video_recorder.dart';
|
||||
import '../domain/connection_session_state.dart';
|
||||
|
||||
part 'connection_controller.g.dart';
|
||||
|
||||
/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。
|
||||
@Riverpod(keepAlive: true)
|
||||
class ConnectionController extends _$ConnectionController {
|
||||
/// 与 Windows 原生窗口通信的通道(用于按视频比例调整窗口高度)。
|
||||
static const MethodChannel _windowChannel = MethodChannel('app/window');
|
||||
|
||||
RemoteController? _remote;
|
||||
RTCVideoRenderer? _renderer;
|
||||
final VideoRecorder _videoRecorder = VideoRecorder();
|
||||
|
||||
/// 记录上一次已应用的视频宽高比,避免重复调整窗口。
|
||||
double _lastResizedAspect = 0;
|
||||
|
||||
@override
|
||||
ConnectionSessionState build() {
|
||||
ref.onDispose(() {
|
||||
_videoRecorder.dispose();
|
||||
_remote?.disconnect();
|
||||
});
|
||||
return const ConnectionSessionState(status: '状态: 已停止');
|
||||
}
|
||||
|
||||
void _setStatus(String status) => state = state.copyWith(status: status);
|
||||
|
||||
void _alert(String message) => state = state.copyWith(alert: message);
|
||||
|
||||
void _clearAlert() => state = state.copyWith(alert: null);
|
||||
|
||||
/// 发起连接:先确保已登录,携带 Bearer 建立信令,成功后建立 WebRTC。
|
||||
Future<void> connect({
|
||||
required String serverUrl,
|
||||
required String targetDeviceId,
|
||||
required String authType,
|
||||
required String authValue,
|
||||
}) async {
|
||||
if (serverUrl.isEmpty || targetDeviceId.isEmpty) {
|
||||
_alert('请填写服务器地址和目标设备ID');
|
||||
return;
|
||||
}
|
||||
final authRepository = ref.read(authRepositoryProvider);
|
||||
state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...');
|
||||
|
||||
_remote = RemoteController(
|
||||
serverUrl: serverUrl,
|
||||
targetDeviceId: targetDeviceId,
|
||||
authRepository: authRepository,
|
||||
);
|
||||
_wireRemoteCallbacks();
|
||||
await _remote!.connect(authType: authType, authValue: authValue);
|
||||
}
|
||||
|
||||
void _wireRemoteCallbacks() {
|
||||
final remote = _remote!;
|
||||
remote.onStatusChanged = _setStatus;
|
||||
remote.onConnectionEstablished = () {
|
||||
state = state.copyWith(
|
||||
connected: true,
|
||||
connecting: false,
|
||||
status: '状态: 已连接 - 远程控制中',
|
||||
);
|
||||
};
|
||||
remote.onConnectionFailed = (error) {
|
||||
state = state.copyWith(connecting: false);
|
||||
_alert('连接失败:$error');
|
||||
};
|
||||
remote.onDisconnected = () {
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
_setStatus('状态: 远端已断开');
|
||||
};
|
||||
remote.onIceDisconnected = (message) {
|
||||
_alert(message);
|
||||
disconnect();
|
||||
};
|
||||
remote.onTargetOffline = (message) {
|
||||
_alert(message);
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
};
|
||||
remote.onConnectionRejected = (message) {
|
||||
_alert(message);
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
};
|
||||
remote.onRemoteStream = (renderer) {
|
||||
_renderer = renderer;
|
||||
state = state.copyWith(renderer: renderer);
|
||||
renderer.addListener(_onRendererUpdate);
|
||||
if (state.pendingRecordStart) {
|
||||
state = state.copyWith(pendingRecordStart: false);
|
||||
_startRecording();
|
||||
}
|
||||
};
|
||||
remote.onStats = (stats) => state = state.copyWith(stats: stats);
|
||||
remote.onSelfCodecReady = (textureId) {
|
||||
state = state.copyWith(
|
||||
selfCodecTextureId: textureId,
|
||||
selfCodecReady: true,
|
||||
);
|
||||
};
|
||||
remote.onSelfCodecLost = () {
|
||||
state = state.copyWith(selfCodecReady: false, selfCodecTextureId: null);
|
||||
};
|
||||
remote.onStreamModeReport = (mode) {
|
||||
state = state.copyWith(streamMode: mode);
|
||||
if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) {
|
||||
state = state.copyWith(pendingRecordStart: false);
|
||||
_startRecording();
|
||||
}
|
||||
};
|
||||
remote.onResolutionReported = (w, h) {
|
||||
if (w > 0 && h > 0) {
|
||||
final aspect = w / h;
|
||||
state = state.copyWith(videoAspect: aspect);
|
||||
_resizeWindowToAspect(aspect);
|
||||
}
|
||||
};
|
||||
remote.onFpsReport = (w, h, fps, supportedFps) {
|
||||
state = state.copyWith(
|
||||
lastReportedWidth: w > 0 ? w : state.lastReportedWidth,
|
||||
lastReportedHeight: h > 0 ? h : state.lastReportedHeight,
|
||||
currentFps: fps > 0 ? fps : state.currentFps,
|
||||
fpsOptions: supportedFps.isNotEmpty ? supportedFps : state.fpsOptions,
|
||||
);
|
||||
};
|
||||
remote.onSelfCodecNotSupported = () {
|
||||
state = state.copyWith(selfCodecSupported: false);
|
||||
_alert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
|
||||
};
|
||||
remote.onTokenExpired = () {
|
||||
_alert('登录已失效,请重新登录后再连接。');
|
||||
_resetLogin();
|
||||
};
|
||||
remote.onForceLogout = () {
|
||||
_alert('账号已在其他位置登录,已强制下线。');
|
||||
_resetLogin();
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _resetLogin() async {
|
||||
await ref.read(authRepositoryProvider).clear();
|
||||
await disconnect();
|
||||
state = state.copyWith(connected: false, connecting: false);
|
||||
}
|
||||
|
||||
void _onRendererUpdate() {
|
||||
final w = _renderer?.value.width ?? 0;
|
||||
final h = _renderer?.value.height ?? 0;
|
||||
if (w > 0 && h > 0) {
|
||||
final aspect = w / h;
|
||||
state = state.copyWith(videoAspect: aspect);
|
||||
_resizeWindowToAspect(aspect);
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 端:保持窗口宽度不变,按视频宽高比调整窗口高度。
|
||||
void _resizeWindowToAspect(double aspect) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.windows) return;
|
||||
if (aspect <= 0) return;
|
||||
if ((aspect - _lastResizedAspect).abs() < 0.001) return;
|
||||
_lastResizedAspect = aspect;
|
||||
_windowChannel.invokeMethod('resizeToVideoAspect', aspect);
|
||||
}
|
||||
|
||||
/// 切换分辨率:写入状态并下发指令。
|
||||
void selectResolution(int index) {
|
||||
state = state.copyWith(selectedResolution: index);
|
||||
final o = state.resolutionOptions[index];
|
||||
_remote?.sendResolutionChange(
|
||||
o['width'] as int,
|
||||
o['height'] as int,
|
||||
o['fps'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换帧率:仅切帧率,分辨率保持不变。
|
||||
void selectFps(int fps) {
|
||||
if (fps <= 0 || fps == state.currentFps) return;
|
||||
_remote?.sendResolutionChange(
|
||||
state.lastReportedWidth,
|
||||
state.lastReportedHeight,
|
||||
fps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换串流模式:WebRTC 全托管 <-> 自编码。
|
||||
void toggleStreamMode() {
|
||||
if (!state.selfCodecSupported) {
|
||||
_alert('当前平台不支持自编码硬解。');
|
||||
return;
|
||||
}
|
||||
final next = state.streamMode == SelfCodecDecoder.streamModeSelfCodec
|
||||
? SelfCodecDecoder.streamModeWebRtc
|
||||
: SelfCodecDecoder.streamModeSelfCodec;
|
||||
state = state.copyWith(streamMode: next);
|
||||
_remote?.sendStreamMode(next);
|
||||
}
|
||||
|
||||
/// 切换远程视频录制:开始 / 停止。
|
||||
Future<void> toggleRecord() async {
|
||||
if (state.recording) {
|
||||
await _stopRecording();
|
||||
return;
|
||||
}
|
||||
final renderer = _renderer;
|
||||
if (renderer == null) {
|
||||
_alert('尚未连接或没有视频画面,无法录制');
|
||||
return;
|
||||
}
|
||||
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec) {
|
||||
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
|
||||
state = state.copyWith(
|
||||
pendingRecordStart: true,
|
||||
recordStatus: '正在切回标准模式以开始录制...',
|
||||
);
|
||||
_remote?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
|
||||
return;
|
||||
}
|
||||
await _startRecording();
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
if (state.recording) return;
|
||||
final stream = _renderer?.srcObject;
|
||||
if (stream == null) {
|
||||
_alert('尚未接收到视频画面,无法录制');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final ok = await _videoRecorder.start(stream);
|
||||
if (ok) {
|
||||
state = state.copyWith(recording: true, recordStatus: '录制中...');
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(recordStatus: '');
|
||||
_alert('开始录制失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording() async {
|
||||
final path = await _videoRecorder.stop();
|
||||
state = state.copyWith(
|
||||
recording: false,
|
||||
recordStatus: path != null ? '已保存:$path' : '录制已停止',
|
||||
);
|
||||
}
|
||||
|
||||
/// 发送控制指令(protobuf 二进制)。
|
||||
void sendControlCommand(ControlMessage command) {
|
||||
_remote?.sendControlCommand(command);
|
||||
}
|
||||
|
||||
/// 断开连接并复位全部 UI 状态。
|
||||
Future<void> disconnect() async {
|
||||
await _videoRecorder.dispose();
|
||||
await _remote?.disconnect();
|
||||
_remote = null;
|
||||
_renderer?.removeListener(_onRendererUpdate);
|
||||
_renderer = null;
|
||||
_lastResizedAspect = 0;
|
||||
state = const ConnectionSessionState(status: '状态: 已停止');
|
||||
}
|
||||
|
||||
/// 消费一次性提示消息(UI 展示后调用)。
|
||||
void consumeAlert() => _clearAlert();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'connection_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$connectionControllerHash() =>
|
||||
r'bea162831e84bb80f7921d7c774d76e07969b023';
|
||||
|
||||
/// 连接控制会话控制器:编排信令 + WebRTC,管理控制端全部 UI 状态。
|
||||
///
|
||||
/// Copied from [ConnectionController].
|
||||
@ProviderFor(ConnectionController)
|
||||
final connectionControllerProvider =
|
||||
NotifierProvider<ConnectionController, ConnectionSessionState>.internal(
|
||||
ConnectionController.new,
|
||||
name: r'connectionControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$connectionControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ConnectionController = Notifier<ConnectionSessionState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../../core/utils/control_commands.dart';
|
||||
import '../../../../core/widgets/remote_touch_view.dart';
|
||||
import '../../data/self_codec_decoder.dart';
|
||||
import '../../domain/connection_session_state.dart';
|
||||
import '../connection_controller.dart';
|
||||
|
||||
/// 控制面板页:显示远端视频、触摸控制、顶部菜单栏与状态浮层。
|
||||
class ControlPage extends ConsumerStatefulWidget {
|
||||
const ControlPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ControlPage> createState() => _ControlPageState();
|
||||
}
|
||||
|
||||
class _ControlPageState extends ConsumerState<ControlPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if (next.alert != null) {
|
||||
final message = next.alert!;
|
||||
// 消费一次性提示消息
|
||||
Future.microtask(() {
|
||||
ref.read(connectionControllerProvider.notifier).consumeAlert();
|
||||
});
|
||||
_showAlert(message);
|
||||
}
|
||||
// 断开连接后返回设置页
|
||||
if ((prev?.connected ?? false) && !next.connected) {
|
||||
context.go('/');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: null,
|
||||
child: SafeArea(
|
||||
// 仅保留顶部安全区(避开系统状态栏),底部/左右保持全屏,
|
||||
// 以便右下角的断开按钮贴近屏幕边缘。
|
||||
top: true,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(color: CupertinoColors.black),
|
||||
_buildVideoLayer(connection),
|
||||
_buildStatusOverlay(connection),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _buildTopMenuBar(connection, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoLayer(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
final touchLayer = RemoteTouchView(
|
||||
// 禁用离散的 TOUCH/SWIPE/LONG_PRESS 指令,改用实时的 onMotionEvent 以解决重复操作问题。
|
||||
// 原始的动作流已包含完整的触摸过程,被控端系统会自动识别单击、滑动和长按。
|
||||
onTouch: (x, y) {},
|
||||
onSwipe: (x1, y1, x2, y2, d) {},
|
||||
onLongPress: (x, y) {},
|
||||
onKey: (k, a) => controller.sendControlCommand(ControlCommands.key(k, a)),
|
||||
onMotionEvent: (a, x, y) =>
|
||||
controller.sendControlCommand(ControlCommands.motionEvent(a, x, y)),
|
||||
);
|
||||
|
||||
if (state.streamMode == SelfCodecDecoder.streamModeSelfCodec &&
|
||||
state.selfCodecReady &&
|
||||
state.selfCodecTextureId != null) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: state.videoAspect,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Texture(textureId: state.selfCodecTextureId!),
|
||||
touchLayer,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.renderer != null) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: state.videoAspect,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RTCVideoView(
|
||||
state.renderer!,
|
||||
objectFit:
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||
),
|
||||
touchLayer,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildStatusOverlay(ConnectionSessionState state) {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.54),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(state.status, style: const TextStyle(color: CupertinoColors.white)),
|
||||
Text(
|
||||
state.stats,
|
||||
style: const TextStyle(color: CupertinoColors.white, fontSize: 12),
|
||||
),
|
||||
if (state.recordStatus.isNotEmpty)
|
||||
Text(
|
||||
state.recordStatus,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.systemOrange,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右上角浮动顶部菜单栏:整合「分辨率切换」与「断开连接」。
|
||||
Widget _buildTopMenuBar(ConnectionSessionState state, AppLocalizations l10n) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
final label = state.resolutionOptions[state.selectedResolution]['label']
|
||||
as String;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 分辨率菜单按钮:显示当前分辨率标签
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () => _showResolutionMenu(state),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.slider_horizontal_3,
|
||||
color: CupertinoColors.white, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 帧率菜单按钮:显示被控端当前采集帧率
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () => _showFpsMenu(state),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.speedometer,
|
||||
color: CupertinoColors.white, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
state.currentFps > 0 ? '${state.currentFps}fps' : '帧率',
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: state.selfCodecSupported ? controller.toggleStreamMode : null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.selfCodec,
|
||||
style: TextStyle(
|
||||
color: state.selfCodecSupported
|
||||
? CupertinoColors.white
|
||||
: CupertinoColors.white.withValues(alpha: 0.4),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
CupertinoSwitch(
|
||||
value: state.streamMode ==
|
||||
SelfCodecDecoder.streamModeSelfCodec,
|
||||
onChanged: state.selfCodecSupported
|
||||
? (_) => controller.toggleStreamMode()
|
||||
: null,
|
||||
activeTrackColor: CupertinoColors.activeBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 远程视频录制开关
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: controller.toggleRecord,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
state.recording
|
||||
? CupertinoIcons.stop_circle
|
||||
: CupertinoIcons.video_camera,
|
||||
color: state.recording
|
||||
? CupertinoColors.destructiveRed
|
||||
: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
state.recording ? l10n.stop : l10n.record,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
height: 22,
|
||||
color: CupertinoColors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
// 断开连接按钮
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () async {
|
||||
await ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
if (mounted) context.go('/');
|
||||
},
|
||||
child: const Icon(
|
||||
CupertinoIcons.xmark_circle_fill,
|
||||
color: CupertinoColors.destructiveRed,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
|
||||
void _showResolutionMenu(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换分辨率'),
|
||||
actions: [
|
||||
for (int i = 0; i < state.resolutionOptions.length; i++)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectResolution(i);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (i == state.selectedResolution) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(state.resolutionOptions[i]['label'] as String),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。
|
||||
void _showFpsMenu(ConnectionSessionState state) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换帧率'),
|
||||
actions: [
|
||||
for (final fps in state.fpsOptions)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectFps(fps);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (fps == state.currentFps) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text('${fps}fps'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../../app/constants/app_constants.dart';
|
||||
import '../../../auth/presentation/auth_controller.dart';
|
||||
import '../../../auth/presentation/widgets/login_dialog.dart';
|
||||
import '../../domain/connection_session_state.dart';
|
||||
import '../connection_controller.dart';
|
||||
import '../widgets/auth_dialog.dart';
|
||||
|
||||
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
|
||||
class SetupPage extends ConsumerStatefulWidget {
|
||||
const SetupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SetupPage> createState() => _SetupPageState();
|
||||
}
|
||||
|
||||
class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
final _serverUrlController = TextEditingController(
|
||||
text: kDefaultSignalServer,
|
||||
);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_targetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState = ref.watch(authControllerProvider).valueOrNull;
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
// 连接建立后跳转控制页。
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if ((prev?.connected ?? false) == false && next.connected) {
|
||||
context.go('/control');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.appTitle),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _serverUrlController,
|
||||
placeholder: l10n.serverUrlPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _deviceIdController,
|
||||
placeholder: l10n.deviceIdPlaceholder,
|
||||
enabled: false,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _targetController,
|
||||
placeholder: l10n.targetDeviceIdPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
connection.status,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: (authState?.loggedIn ?? false)
|
||||
? () async {
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.logout();
|
||||
}
|
||||
: () async {
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const LoginDialog(),
|
||||
);
|
||||
if (ok != true && mounted) {
|
||||
ref.read(connectionControllerProvider.notifier)
|
||||
.consumeAlert();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: connection.connecting ? null : _onConnectPressed,
|
||||
child: Text(
|
||||
connection.connecting ? l10n.connecting : l10n.connectDevice,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConnectPressed() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
if (serverUrl.isEmpty || target.isEmpty) {
|
||||
_showAlert(l10n.serverAndTargetRequired);
|
||||
return;
|
||||
}
|
||||
// 连接前确保已登录(Bearer token)。
|
||||
final authState = ref.read(authControllerProvider).valueOrNull;
|
||||
final loggedIn = authState?.loggedIn ?? false;
|
||||
if (!loggedIn) {
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const LoginDialog(),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
|
||||
final selection = await showAuthDialog(context);
|
||||
if (selection == null || !mounted) return;
|
||||
|
||||
await ref.read(connectionControllerProvider.notifier).connect(
|
||||
serverUrl: serverUrl,
|
||||
targetDeviceId: target,
|
||||
authType: selection.type,
|
||||
authValue: selection.value,
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlert(String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
/// 鉴权方式枚举。
|
||||
enum AuthMode { none, code, password }
|
||||
|
||||
/// 连接鉴权对话框:选择免密 / 动态验证码 / 固定密码。
|
||||
///
|
||||
/// 返回 [AuthSelection];取消返回 null。
|
||||
Future<AuthSelection?> showAuthDialog(BuildContext context) {
|
||||
return showCupertinoDialog<AuthSelection>(
|
||||
context: context,
|
||||
builder: (ctx) => const AuthDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class AuthDialog extends StatefulWidget {
|
||||
const AuthDialog({super.key});
|
||||
|
||||
@override
|
||||
State<AuthDialog> createState() => _AuthDialogState();
|
||||
}
|
||||
|
||||
class _AuthDialogState extends State<AuthDialog> {
|
||||
AuthMode _selected = AuthMode.none;
|
||||
final _valueController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_valueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
Widget buildOption(
|
||||
AuthMode mode,
|
||||
String title,
|
||||
String desc,
|
||||
) {
|
||||
final selected = _selected == mode;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selected = mode),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? CupertinoColors.activeBlue
|
||||
: CupertinoColors.systemGrey4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: selected
|
||||
? CupertinoColors.activeBlue.withValues(alpha: 0.06)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
selected
|
||||
? CupertinoIcons.check_mark_circled_solid
|
||||
: CupertinoIcons.circle,
|
||||
color: selected
|
||||
? CupertinoColors.activeBlue
|
||||
: CupertinoColors.systemGrey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CupertinoAlertDialog(
|
||||
title: Text(l10n.authTitle),
|
||||
content: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
buildOption(
|
||||
AuthMode.none,
|
||||
l10n.authNone,
|
||||
l10n.authNoneDesc,
|
||||
),
|
||||
buildOption(
|
||||
AuthMode.code,
|
||||
l10n.authCode,
|
||||
l10n.authCodeDesc,
|
||||
),
|
||||
buildOption(
|
||||
AuthMode.password,
|
||||
l10n.authPassword,
|
||||
l10n.authPasswordDesc,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CupertinoTextField(
|
||||
controller: _valueController,
|
||||
enabled: _selected != AuthMode.none,
|
||||
placeholder: switch (_selected) {
|
||||
AuthMode.none => l10n.authNonePlaceholder,
|
||||
AuthMode.password => l10n.authPasswordPlaceholder,
|
||||
AuthMode.code => l10n.authCodePlaceholder,
|
||||
},
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.cancel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.connect),
|
||||
onPressed: () {
|
||||
final val = _valueController.text.trim();
|
||||
if (_selected != AuthMode.none && val.isEmpty) {
|
||||
_showAlert(context, l10n.authValueRequired);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
AuthSelection(
|
||||
mode: _selected,
|
||||
value: _selected == AuthMode.none ? '' : val,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static void _showAlert(BuildContext context, String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 鉴权选择结果。
|
||||
class AuthSelection {
|
||||
final AuthMode mode;
|
||||
final String value;
|
||||
const AuthSelection({required this.mode, required this.value});
|
||||
|
||||
String get type => switch (mode) {
|
||||
AuthMode.none => 'NONE',
|
||||
AuthMode.code => 'CODE',
|
||||
AuthMode.password => 'PASSWORD',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user