删除未使用的相机和记录类来重构记录功能;更新pubspec中的依赖项。Yaml为本地插件路径;简化MainActivity和相关类,以提高性能和可维护性。

This commit is contained in:
2026-07-09 16:17:34 +08:00
parent 75c42ea168
commit 6138eddc16
65 changed files with 3677 additions and 2672 deletions
+1 -31
View File
@@ -1,46 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/app/theme/app_theme.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
class FlutterTemplateApp extends ConsumerStatefulWidget {
class FlutterTemplateApp extends StatelessWidget {
const FlutterTemplateApp({super.key});
@override
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
}
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
+1
View File
@@ -108,6 +108,7 @@ class ApiClient {
final statusCode = error.response?.statusCode;
final message = switch (error.type) {
DioExceptionType.connectionTimeout => '网络连接超时',
DioExceptionType.transformTimeout => '网络请求处理超时',
DioExceptionType.sendTimeout => '请求发送超时',
DioExceptionType.receiveTimeout => '响应接收超时',
DioExceptionType.badCertificate => '证书校验失败',
+24 -1
View File
@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
class EventInfoPage extends StatelessWidget {
const EventInfoPage({super.key});
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
static const _profile = EventProfile(
avatarLabel: '头像',
name: '王东方',
@@ -45,7 +49,12 @@ class EventInfoPage extends StatelessWidget {
];
void onItemTap(EventScheduleItem item) {
debugPrint('item tapped: ${item.name}');
AppNavigator.push(const RecordingPage());
AppNavigator.push(
RecordingPage(
recordingContext: item.toRecordingContext(profile: _profile),
streamUrl: mockRtmpUrl,
),
);
}
@override
@@ -125,6 +134,20 @@ class EventScheduleItem {
final String time;
final String? laneNo;
final String? status;
RecordingContext toRecordingContext({required EventProfile profile}) {
return RecordingContext(
eventTitle: profile.eventTitle,
matchName: name,
group: group,
venue: venue,
time: time,
playerName: profile.name,
playerPhone: profile.phone,
laneNo: laneNo,
status: status,
);
}
}
class _Header extends StatelessWidget {
@@ -1,61 +0,0 @@
/// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel {
final String title;
int? startTimestamp;
int? endTimestamp;
final String address;
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
final String? filename;
ClipboardRecordingModel({
required this.title,
this.startTimestamp,
this.endTimestamp,
required this.address,
this.filename,
});
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
return ClipboardRecordingModel(
title: _readString(json, 'title'),
startTimestamp: _readOptionalInt(json, 'startTimestamp'),
endTimestamp: _readOptionalInt(json, 'endTimestamp'),
address: _readString(json, 'address'),
filename: _readOptionalString(json, 'filename'),
);
}
Map<String, dynamic> toJson() {
return {
'title': title,
'startTimestamp': startTimestamp,
'endTimestamp': endTimestamp,
'address': address,
if (filename != null) 'filename': filename,
};
}
static String? _readOptionalString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) return null;
if (value is String && value.isNotEmpty) return value;
if (value is! String) {
throw FormatException('Clipboard field "$key" must be a String.');
}
return null;
}
static String _readString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String) return value;
throw FormatException('Clipboard field "$key" must be a String.');
}
static int? _readOptionalInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) return null;
if (value is int) return value;
throw FormatException('Clipboard field "$key" must be an int.');
}
}
@@ -1,51 +1,26 @@
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
class RecordingModel {
/// 剪切板内容
final ClipboardRecordingModel clipboardRecordingModel;
/// 剪切板是否包含有效的小程序录制信息
final bool hasValidClipboardInfo;
/// 从赛事项进入录制页时传入的录制上下文。
final RecordingContext recordingContext;
/// 录制会话状态
final RecordingSessionState session;
RecordingModel({
required this.clipboardRecordingModel,
this.hasValidClipboardInfo = false,
required this.recordingContext,
this.session = const RecordingSessionState(),
});
bool get isRecording => session.isRecording;
factory RecordingModel.fromJson(Map<String, dynamic> json) {
return RecordingModel(
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
json['clipboardRecordingModel'],
),
);
}
Map<String, dynamic> toJson() {
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
}
/// 剪切板是否包含可用于命名的 [ClipboardRecordingModel.filename]。
bool get hasClipboardFilename {
final name = clipboardRecordingModel.filename?.trim();
return hasValidClipboardInfo && name != null && name.isNotEmpty;
}
RecordingModel copyWith({
ClipboardRecordingModel? clipboardRecordingModel,
bool? hasValidClipboardInfo,
RecordingContext? recordingContext,
RecordingSessionState? session,
}) {
return RecordingModel(
clipboardRecordingModel:
clipboardRecordingModel ?? this.clipboardRecordingModel,
hasValidClipboardInfo:
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
recordingContext: recordingContext ?? this.recordingContext,
session: session ?? this.session,
);
}
@@ -0,0 +1,55 @@
class RecordingContext {
const RecordingContext({
required this.eventTitle,
required this.matchName,
required this.group,
required this.venue,
required this.time,
required this.playerName,
required this.playerPhone,
this.laneNo,
this.status,
});
const RecordingContext.empty()
: eventTitle = '',
matchName = '',
group = '',
venue = '',
time = '',
playerName = '',
playerPhone = '',
laneNo = null,
status = null;
final String eventTitle;
final String matchName;
final String group;
final String venue;
final String time;
final String playerName;
final String playerPhone;
final String? laneNo;
final String? status;
String get title => '$matchName $group';
String get address => venue;
String get displayName {
final parts = [
playerName,
matchName,
group,
if (laneNo != null && laneNo!.trim().isNotEmpty) laneNo!,
];
return parts.map(_sanitize).where((part) => part.isNotEmpty).join('_');
}
static String _sanitize(String value) {
return value
.trim()
.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_')
.replaceAll(RegExp(r'\s+'), '_');
}
}
@@ -15,12 +15,11 @@ class RecordingSessionState {
this.zoomRatio = 1.0,
this.minZoomRatio = 1.0,
this.maxZoomRatio = 3.0,
this.lastOutputPath,
this.lastSavedDisplayName,
this.lastStreamUrl,
this.errorMessage,
this.permissionWarning,
this.fileSaveFailed = false,
this.segmentOutputPaths = const [],
this.streamFinished = false,
this.streamFailed = false,
});
final RecordingStatus status;
@@ -35,12 +34,11 @@ class RecordingSessionState {
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? lastStreamUrl;
final String? errorMessage;
final String? permissionWarning;
final bool fileSaveFailed;
final List<String> segmentOutputPaths;
final bool streamFinished;
final bool streamFailed;
bool get isRecording => status.isRecording;
@@ -65,14 +63,13 @@ class RecordingSessionState {
double? zoomRatio,
double? minZoomRatio,
double? maxZoomRatio,
String? lastOutputPath,
String? lastSavedDisplayName,
String? lastStreamUrl,
String? errorMessage,
String? permissionWarning,
bool? fileSaveFailed,
List<String>? segmentOutputPaths,
bool? streamFinished,
bool? streamFailed,
bool clearPermissionWarning = false,
bool clearLastSaved = false,
bool clearStreamResult = false,
}) {
return RecordingSessionState(
status: status ?? this.status,
@@ -88,16 +85,17 @@ class RecordingSessionState {
zoomRatio: zoomRatio ?? this.zoomRatio,
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
lastStreamUrl: lastStreamUrl ?? this.lastStreamUrl,
errorMessage: errorMessage,
permissionWarning: clearPermissionWarning
? null
: (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths,
streamFinished: clearStreamResult
? false
: (streamFinished ?? this.streamFinished),
streamFailed: clearStreamResult
? false
: (streamFailed ?? this.streamFailed),
);
}
}
+235 -118
View File
@@ -1,15 +1,16 @@
import 'dart:io';
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
import 'package:recording_tool/core/platform/device_health_checker.dart';
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
@@ -19,11 +20,20 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_hud.d
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
/// 录制页入口
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
const RecordingPage({
super.key,
required this.recordingContext,
required this.streamUrl,
});
final RecordingContext recordingContext;
final String streamUrl;
@override
/// 创建页面状态
@@ -31,13 +41,60 @@ class RecordingPage extends ConsumerStatefulWidget {
}
class _RecordingPageState extends ConsumerState<RecordingPage> {
late final ApiVideoLiveStreamController _streamController;
var _immersiveApplied = false;
var _previewReady = false;
var _stoppingByUser = false;
var _controllerDisposed = false;
String? _mainCameraId;
String? _ultraWideCameraId;
double _ultraWideZoomRatio = 1.0;
@override
/// 首帧后初始化录制流程
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
_streamController = ApiVideoLiveStreamController(
initialAudioConfig: AudioConfig(bitrate: 128000),
initialVideoConfig: VideoConfig.withDefaultBitrate(
resolution: Resolution.RESOLUTION_1080,
fps: 30,
),
onConnectionSuccess: () => {debugPrint('推流成功')},
onConnectionFailed: (reason) {
debugPrint('推流失败');
if (!mounted) return;
ref
.read(recordingViewModelProvider.notifier)
.markRecordingStartFailed('推流失败: $reason');
},
onDisconnection: () {
debugPrint('推流连接已断开');
if (!mounted || _stoppingByUser) return;
final isRecording = ref.read(recordingViewModelProvider).isRecording;
if (isRecording) {
ref
.read(recordingViewModelProvider.notifier)
.markRecordingStopped(errorMessage: '推流连接已断开');
}
},
onError: (error) {
debugPrint('推流失败:${error.toString()}');
if (!mounted) return;
ref
.read(recordingViewModelProvider.notifier)
.setError(error.toString());
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(recordingViewModelProvider.notifier)
.setRecordingContext(widget.recordingContext);
_bootstrap();
});
}
/// 检查设备健康状态并弹窗提示
@@ -55,23 +112,81 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
);
}
/// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话
/// 页面启动:健康检查、进入录制模式、准备相机会话
Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
final clipboardResult = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (clipboardResult == ClipboardReadResult.invalid) {
AppToast.show('无选手信息');
}
await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
await ref.read(recordingViewModelProvider.notifier).prepareSession();
if (!mounted) return;
await _initializeLiveStreamPreview();
}
Future<void> _initializeLiveStreamPreview() async {
try {
await _streamController.initialize();
if (!mounted) return;
setState(() => _previewReady = true);
ref
.read(recordingViewModelProvider.notifier)
.setPreviewReady(ready: true);
await _loadBackCameraCapabilities();
} on PlatformException catch (error) {
if (!mounted) return;
ref
.read(recordingViewModelProvider.notifier)
.setPreviewReady(
ready: false,
errorMessage: error.message ?? '相机预览初始化失败',
);
if (mounted) setState(() => _previewReady = false);
} catch (error) {
if (!mounted) return;
ref
.read(recordingViewModelProvider.notifier)
.setPreviewReady(ready: false, errorMessage: '相机预览初始化失败: $error');
setState(() => _previewReady = false);
}
}
Future<void> _loadBackCameraCapabilities() async {
try {
final cameras = await _streamController.getBackCameras();
if (cameras.isEmpty) return;
final main = cameras.first;
final widest = cameras.reduce(
(current, next) =>
next.horizontalFov > current.horizontalFov ? next : current,
);
_mainCameraId = main.cameraId;
if (widest.cameraId != main.cameraId &&
widest.horizontalFov > main.horizontalFov * 1.08 &&
main.minFocalLength > 0) {
_ultraWideCameraId = widest.cameraId;
_ultraWideZoomRatio = (widest.minFocalLength / main.minFocalLength)
.clamp(0.4, 0.9)
.toDouble();
}
ref
.read(recordingViewModelProvider.notifier)
.updateZoomCapabilities(
zoomRatio: 1.0,
minZoomRatio: _ultraWideCameraId == null
? 1.0
: _ultraWideZoomRatio,
maxZoomRatio: 1.0,
);
} catch (_) {
ref
.read(recordingViewModelProvider.notifier)
.updateZoomCapabilities(
zoomRatio: 1.0,
minZoomRatio: 1.0,
maxZoomRatio: 1.0,
);
}
}
/// Android 进入沉浸式全屏
@@ -82,43 +197,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true;
}
/// 解析保存成功弹窗的标题文案
String _savedDialogSessionTitle(
RecordingModel recordingInfo,
String? savedName,
) {
final clipboard = recordingInfo.clipboardRecordingModel;
if (recordingInfo.hasValidClipboardInfo &&
clipboard.title.trim().isNotEmpty) {
return clipboard.title.trim();
}
if (savedName != null && savedName.isNotEmpty) {
return resolveRecordingDisplayName(savedName);
}
return '录制完成';
}
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
Future<void> _pasteEventInfo() async {
final result = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (result != ClipboardReadResult.success) {
AppToast.show('无选手信息');
}
}
/// 无选手信息时弹窗提示
Future<void> _showNoPlayerInfoDialog() {
return RecordDialog.showSingle(
context,
title: '无选手信息!',
buttonText: '粘贴',
onPressed: _pasteEventInfo,
);
}
/// 根据缺失权限生成弹窗文案。
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
if (!result.cameraGranted && !result.microphoneGranted) {
@@ -153,65 +231,109 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
return false;
}
/// 点击开始录制:校验剪贴板、权限与健康状态
/// 点击开始录制:校验推流地址、权限与健康状态
Future<void> _onStartRecording() async {
final recordingInfo = ref.read(recordingViewModelProvider);
if (!recordingInfo.hasClipboardFilename) {
await _showNoPlayerInfoDialog();
if (widget.streamUrl.trim().isEmpty) {
AppToast.show('推流地址为空,请重新进入录制页');
return;
}
if (!await _ensureRecordingPermissions()) return;
if (!mounted) return;
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
await ref.read(recordingViewModelProvider.notifier).startRecording();
final viewModel = ref.read(recordingViewModelProvider.notifier);
viewModel.markStartingRecording();
try {
final target = RtmpStreamTarget.parse(widget.streamUrl);
await _streamController.startStreaming(
streamKey: target.streamKey,
url: target.url,
);
if (!mounted) return;
await viewModel.markRecordingStarted(target.fullUrl);
} on FormatException catch (error) {
viewModel.markRecordingStartFailed(error.message);
AppToast.show(error.message);
} on PlatformException catch (error) {
final message = error.message ?? error.code;
viewModel.markRecordingStartFailed('推流失败: $message');
AppToast.show('推流失败: $message');
} catch (error) {
viewModel.markRecordingStartFailed('推流失败: $error');
AppToast.showError(error);
}
}
/// 停止录制并按结果显示保存提示。
/// 停止录制并按结果显示推流提示。
Future<void> _stopRecordingAndShowResult() async {
await ref.read(recordingViewModelProvider.notifier).stopRecording();
if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) {
if (latest.segmentOutputPaths.isNotEmpty) {
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
} else {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
}
final viewModel = ref.read(recordingViewModelProvider.notifier);
_stoppingByUser = true;
try {
await _streamController.stopStreaming();
if (!mounted) return;
await viewModel.markRecordingStopped();
} on PlatformException catch (error) {
final message = error.message ?? error.code;
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $message');
AppToast.show('停止推流失败: $message');
return;
} catch (error) {
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $error');
AppToast.showError(error);
return;
} finally {
_stoppingByUser = false;
}
await _showRecordingFinishedDialogIfNeeded();
}
Future<void> _setCameraZoomRatio(double ratio) async {
final viewModel = ref.read(recordingViewModelProvider.notifier);
final targetCameraId = ratio < 1.0 ? _ultraWideCameraId : _mainCameraId;
if (targetCameraId == null) {
viewModel.setError('当前设备不支持该镜头');
return;
}
await _showRecordingSavedDialogIfNeeded();
viewModel.markLensSwitching(true);
try {
await _streamController.setCameraId(targetCameraId);
viewModel.setZoomRatioValue(ratio < 1.0 ? _ultraWideZoomRatio : 1.0);
} on PlatformException catch (error) {
final isRecording = ref.read(recordingViewModelProvider).isRecording;
final message = isRecording
? '推流中切换镜头失败,请停止后重试'
: (error.message ?? '切换镜头失败,请重试');
viewModel.setError(message);
} catch (_) {
final isRecording = ref.read(recordingViewModelProvider).isRecording;
viewModel.setError(isRecording ? '推流中切换镜头失败,请停止后重试' : '切换镜头失败,请重试');
} finally {
viewModel.markLensSwitching(false);
}
}
/// 清空剪贴板信息,准备新一轮录制
void _clearClipboardForNewRound() {
final notifier = ref.read(recordingViewModelProvider.notifier);
notifier.resetClipboardInfo();
notifier.clearSavedRecordingResult();
/// 返回扫码页,准备新一轮录制
void _recordNewRound() {
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
}
/// 保存成功后按需弹出完成对话框
Future<void> _showRecordingSavedDialogIfNeeded() async {
/// 推流结束后按需弹出完成对话框
Future<void> _showRecordingFinishedDialogIfNeeded() async {
final recordingInfo = ref.read(recordingViewModelProvider);
final session = recordingInfo.session;
if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
if (!session.streamFinished || session.streamFailed) {
return;
}
final sessionTitle = _savedDialogSessionTitle(
recordingInfo,
session.lastSavedDisplayName,
);
await showRecordingSavedDialog(
context,
sessionTitle: sessionTitle,
sessionTitle: recordingInfo.recordingContext.title,
onContinueRound: () {
ref
.read(recordingViewModelProvider.notifier)
.clearSavedRecordingResult();
},
onRecordNewRound: _clearClipboardForNewRound,
onRecordNewRound: _recordNewRound,
);
}
@@ -219,6 +341,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingViewModelProvider.notifier).teardown();
await _disposeStreamController();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
@@ -237,9 +360,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
);
RecordingPlatform.setImmersiveMode(enabled: false);
}
_disposeStreamController();
super.dispose();
}
Future<void> _disposeStreamController() async {
if (_controllerDisposed) return;
_controllerDisposed = true;
await _streamController.stop();
await _streamController.dispose();
}
@override
/// 构建录制页 UI
Widget build(BuildContext context) {
@@ -249,19 +380,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
backgroundColor: Colors.black,
body: Column(
children: [
_RecordHeaderSection(
onPasteEventInfo: _pasteEventInfo,
onClearEventInfo: _clearClipboardForNewRound,
),
const _RecordHeaderSection(),
Expanded(
child: Stack(
children: [
const CameraPreviewWidget(),
CameraPreviewWidget(
controller: _streamController,
isReady: _previewReady,
),
const _PreviewLoadingLayer(),
const RecordTimerWidget(),
_RecordingHudLayer(
onStart: _onStartRecording,
onStop: _stopRecordingAndShowResult,
onZoomSelected: _setCameraZoomRatio,
),
_TouchLockOverlayLayer(
onStopRecording: _stopRecordingAndShowResult,
@@ -310,34 +442,18 @@ class _RecordingPopScope extends ConsumerWidget {
}
class _RecordHeaderSection extends ConsumerWidget {
const _RecordHeaderSection({
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
const _RecordHeaderSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final headerState = ref.watch(
recordingViewModelProvider.select(
(m) => (
m.hasValidClipboardInfo,
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
m.session.isRecording,
),
(m) => (m.recordingContext.title, m.session.isRecording),
),
);
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
final (eventTitle, isRecording) = headerState;
return RecordHeaderWidget(
hasValidClipboardInfo: hasValidClipboardInfo,
eventTitle: eventTitle,
isRecording: isRecording,
onPasteEventInfo: onPasteEventInfo,
onClearEventInfo: onClearEventInfo,
);
return RecordHeaderWidget(eventTitle: eventTitle, isRecording: isRecording);
}
}
@@ -361,10 +477,15 @@ class _PreviewLoadingLayer extends ConsumerWidget {
}
class _RecordingHudLayer extends ConsumerWidget {
const _RecordingHudLayer({required this.onStart, required this.onStop});
const _RecordingHudLayer({
required this.onStart,
required this.onStop,
required this.onZoomSelected,
});
final Future<void> Function() onStart;
final Future<void> Function() onStop;
final ValueChanged<double> onZoomSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -383,8 +504,7 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.zoomRatio,
m.session.minZoomRatio,
m.session.maxZoomRatio,
m.hasValidClipboardInfo,
m.clipboardRecordingModel.address.trim(),
m.recordingContext.address.trim(),
),
),
);
@@ -401,8 +521,7 @@ class _RecordingHudLayer extends ConsumerWidget {
zoomRatio,
minZoomRatio,
maxZoomRatio,
showClipboardHint,
clipboardAddress,
venue,
) = hudState;
final viewModel = ref.read(recordingViewModelProvider.notifier);
@@ -419,8 +538,8 @@ class _RecordingHudLayer extends ConsumerWidget {
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress,
showContextHint: venue.isNotEmpty,
contextAddress: venue,
onStart: onStart,
onStop: onStop,
onOpenDnd: () async {
@@ -438,9 +557,7 @@ class _RecordingHudLayer extends ConsumerWidget {
.isTouchLocked;
viewModel.setTouchLocked(!locked);
},
onZoomSelected: (ratio) async {
await viewModel.setZoomRatio(ratio);
},
onZoomSelected: onZoomSelected,
);
}
}
@@ -491,7 +608,7 @@ class _StartingRecordingOverlay extends ConsumerWidget {
}
return RecordingLoadingOverlayWidget(
message: '正在开始录制',
message: '正在连接推流',
backgroundColor: Colors.black.withValues(alpha: 0.24),
);
}
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
@@ -22,20 +21,20 @@ enum RecordingState {
class RecordingStatus {
const RecordingStatus({
required this.state,
this.outputPath,
this.streamUrl,
this.elapsedMillis = 0,
this.message,
});
final RecordingState state;
final String? outputPath;
final String? streamUrl;
final int elapsedMillis;
final String? message;
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
return RecordingStatus(
state: RecordingState.fromRaw(map['state'] as String?),
outputPath: map['outputPath'] as String?,
streamUrl: map['streamUrl'] as String?,
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
message: map['message'] as String?,
);
@@ -50,9 +49,6 @@ class RecordingPlatform {
static const MethodChannel _channel = MethodChannel(
RecordingChannelNames.method,
);
static const EventChannel _events = EventChannel(
RecordingChannelNames.events,
);
static bool get isSupported =>
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
@@ -61,76 +57,6 @@ class RecordingPlatform {
return isAndroid || isIOS;
}
static Stream<RecordingStatus>? _statusStream;
static Stream<RecordingStatus> statusStream() {
if (!isSupported) {
return const Stream.empty();
}
_statusStream ??= _events.receiveBroadcastStream().map(
(event) =>
RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
);
return _statusStream!;
}
static Future<RecordingStatus> initializePreview() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'initializePreview',
);
return RecordingStatus.fromMap(result ?? const {});
}
static Future<RecordingZoomCapabilities> getZoomCapabilities() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'getZoomCapabilities',
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingZoomCapabilities> setZoomRatio(double ratio) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'setZoomRatio',
<String, dynamic>{'zoomRatio': ratio},
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingStartResult> startRecording({
bool withAudio = true,
bool enableDoNotDisturb = true,
String? displayName,
}) async {
final args = <String, dynamic>{
'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb,
};
if (displayName != null) {
args['displayName'] = displayName;
}
final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording',
args,
);
return RecordingStartResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
}
static Future<RecordingStopResult> stopRecording() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording',
);
return RecordingStopResult.fromMap(result);
}
static Future<void> disposePreview() =>
_channel.invokeMethod('disposePreview');
static Future<bool> hasNotificationPolicyAccess() async {
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
false;
@@ -164,71 +90,4 @@ class RecordingPlatform {
'enabled': enabled,
});
}
static Future<RecordingStatus> getStatus() async {
final result = await _channel.invokeMapMethod<String, dynamic>('getStatus');
return RecordingStatus.fromMap(result ?? const {});
}
}
class RecordingZoomCapabilities {
const RecordingZoomCapabilities({
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
});
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
factory RecordingZoomCapabilities.fromMap(Map<String, dynamic>? map) {
final minZoomRatio = (map?['minZoomRatio'] as num?)?.toDouble() ?? 1.0;
final maxZoomRatio = (map?['maxZoomRatio'] as num?)?.toDouble() ?? 3.0;
final zoomRatio = (map?['zoomRatio'] as num?)?.toDouble() ?? minZoomRatio;
return RecordingZoomCapabilities(
zoomRatio: zoomRatio.clamp(minZoomRatio, maxZoomRatio).toDouble(),
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
);
}
}
class RecordingStartResult {
const RecordingStartResult({this.outputPath, required this.status});
final String? outputPath;
final RecordingStatus status;
}
class RecordingStopResult {
const RecordingStopResult({
this.outputPath,
required this.status,
this.fileSaved = true,
this.fileErrorMessage,
this.segmentOutputPaths = const [],
});
final String? outputPath;
final RecordingStatus status;
final bool fileSaved;
final String? fileErrorMessage;
final List<String> segmentOutputPaths;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
fileSaved: result?['fileSaved'] as bool? ?? true,
fileErrorMessage: result?['fileErrorMessage'] as String?,
segmentOutputPaths:
(result?['segmentOutputPaths'] as List?)?.whereType<String>().toList(
growable: false,
) ??
const [],
);
}
}
@@ -1,53 +0,0 @@
import 'dart:io';
/// 非法文件名字符(路径分隔符等)。
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
const _maxBaseNameLength = 120;
/// 清洗小程序复制的文件名基底(不含扩展名)。
String? sanitizeRecordingBaseName(String raw) {
var name = raw.replaceAll(_invalidNameChars, '_').trim();
if (name.isEmpty) return null;
if (name.length > _maxBaseNameLength) {
name = name.substring(0, _maxBaseNameLength);
}
return name;
}
/// 解析录制展示名:优先剪切板 filename,否则 REC_时间戳。
String resolveRecordingDisplayName(String? clipboardFilename) {
final sanitized = clipboardFilename == null
? null
: sanitizeRecordingBaseName(clipboardFilename);
if (sanitized != null) return sanitized;
final now = DateTime.now();
final stamp =
'${now.year}'
'${now.month.toString().padLeft(2, '0')}'
'${now.day.toString().padLeft(2, '0')}_'
'${now.hour.toString().padLeft(2, '0')}'
'${now.minute.toString().padLeft(2, '0')}'
'${now.second.toString().padLeft(2, '0')}';
return 'REC_$stamp';
}
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
String withVideoExtension(String baseName, {bool? isIOS}) {
final ios = isIOS ?? Platform.isIOS;
final ext = ios ? '.mov' : '.mp4';
final lower = baseName.toLowerCase();
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
return baseName;
}
return '$baseName$ext';
}
/// 传给原生的完整文件名(含扩展名)。
String recordingFileNameForPlatform(
String? clipboardFilename, {
bool? isIOS,
}) {
final base = resolveRecordingDisplayName(clipboardFilename);
return withVideoExtension(base, isIOS: isIOS);
}
@@ -1,18 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:recording_tool/core/permission/permission_service.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
/// 录制页状态 Provider。
final recordingViewModelProvider =
@@ -20,35 +15,6 @@ final recordingViewModelProvider =
RecordingViewModel.new,
);
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
/// 剪切板为空,不提示
empty,
/// 解析成功
success,
/// 有内容但格式不符合小程序录制信息
invalid,
}
List<Permission> recordingFileSavePermissionsForHost({
required bool isIOS,
required bool isAndroid,
int? androidSdkInt,
}) {
if (isIOS) {
return const [];
}
if (isAndroid) {
if (androidSdkInt != null && androidSdkInt <= 28) {
return [Permission.storage];
}
return const [];
}
return const [];
}
/// 开始录制所需的相机/麦克风权限检测结果。
class RecordingRequiredPermissions {
const RecordingRequiredPermissions({
@@ -62,20 +28,16 @@ class RecordingRequiredPermissions {
bool get allGranted => cameraGranted && microphoneGranted;
}
/// 录制页 ViewModel剪贴板、权限、相机预览与录制流程
/// 录制页 ViewModel赛事上下文、权限、推流与页面状态
class RecordingViewModel extends Notifier<RecordingModel> {
static final _defaultClipboard = ClipboardRecordingModel(
title: '',
address: '',
);
StreamSubscription<RecordingStatus>? _statusSubscription;
Timer? _elapsedTimer;
DateTime? _recordingStartedAt;
/// 初始化状态并注册销毁回调。
@override
RecordingModel build() {
ref.onDispose(_dispose);
return RecordingModel(clipboardRecordingModel: _defaultClipboard);
return RecordingModel(recordingContext: const RecordingContext.empty());
}
/// 局部更新 session 子状态。
@@ -85,76 +47,22 @@ class RecordingViewModel extends Notifier<RecordingModel> {
state = state.copyWith(session: update(state.session));
}
/// 读取并解析剪贴板中的小程序录制信息
Future<ClipboardReadResult> getClipboardContent() async {
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
AppLogger.debug('读取剪切板内容:$text');
if (text == null || text.trim().isEmpty) {
AppLogger.info('剪切板内容为空,跳过录制信息解析');
_resetClipboardInfo();
return ClipboardReadResult.empty;
}
final decoded = jsonDecode(text.trim());
if (decoded is! Map<String, dynamic>) {
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
if (clipboardRecordingModel.title.trim().isEmpty) {
AppLogger.warning('剪切板录制信息缺少有效 title');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
state = state.copyWith(
clipboardRecordingModel: clipboardRecordingModel,
hasValidClipboardInfo: true,
);
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
return ClipboardReadResult.success;
} on FormatException catch (error) {
AppLogger.warning('剪切板录制信息格式错误:$error');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
} catch (error, stackTrace) {
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
/// 设置当前录制页上下文
void setRecordingContext(RecordingContext recordingContext) {
state = state.copyWith(recordingContext: recordingContext);
}
/// 清空剪贴板赛事信息(供 UI 调用)
void resetClipboardInfo() {
_resetClipboardInfo();
}
/// 重置剪贴板赛事信息为默认空值。
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,
hasValidClipboardInfo: false,
);
}
/// 申请权限、检查系统设置并初始化相机预览。
/// 申请权限并检查系统设置
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
return;
}
final fileSavePermissions = await _fileSavePermissions();
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
...fileSavePermissions,
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -176,10 +84,6 @@ class RecordingViewModel extends Notifier<RecordingModel> {
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) {
warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
@@ -196,106 +100,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
),
);
await _listenStatus();
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
),
);
}
_updateSession((s) => s.copyWith(errorMessage: null));
}
/// 初始化相机预览,PlatformView 未就绪时自动重试。
Future<RecordingStatus> _initializePreviewWithRetry() async {
const maxAttempts = 8;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await RecordingPlatform.initializePreview();
} on PlatformException catch (error) {
final shouldRetry =
error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1;
if (!shouldRetry) {
rethrow;
}
await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
}
}
throw StateError('initializePreview retry exhausted');
}
/// 停止录制后重新绑定相机预览,并显示加载遮罩。
Future<void> restorePreview() async {
if (!RecordingPlatform.isSupported) return;
void setPreviewReady({required bool ready, String? errorMessage}) {
_updateSession(
(s) => s.copyWith(isPreviewReady: false, errorMessage: null),
(s) => s.copyWith(isPreviewReady: ready, errorMessage: errorMessage),
);
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
),
);
}
}
/// 当前平台所需的视频文件保存权限列表。
Future<List<Permission>> _fileSavePermissions() async {
int? androidSdkInt;
if (Platform.isAndroid) {
try {
androidSdkInt = int.tryParse(
(await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '',
);
} on PlatformException {
androidSdkInt = null;
}
}
return recordingFileSavePermissionsForHost(
isIOS: Platform.isIOS,
isAndroid: Platform.isAndroid,
androidSdkInt: androidSdkInt,
);
}
/// 判断文件保存相关权限是否至少有一项已授予。
bool _isFileSavePermissionGranted(
Map<Permission, PermissionStatus> permissions,
List<Permission> fileSavePermissions,
) {
for (final permission in fileSavePermissions) {
if (permissions[permission]?.isGranted ?? false) {
return true;
}
}
return fileSavePermissions.isEmpty;
}
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
@@ -315,8 +126,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
if (cameraGranted && !state.session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: null));
await _listenStatus();
await restorePreview();
_updateSession((s) => s.copyWith(isPreviewReady: true));
}
return RecordingRequiredPermissions(
@@ -329,130 +139,115 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return status?.isGranted == true || status?.isLimited == true;
}
/// 读取相机支持的倍距范围并同步当前倍距。
Future<void> _refreshZoomCapabilities() async {
try {
final zoom = await RecordingPlatform.getZoomCapabilities();
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
AppLogger.debug('读取相机倍距能力失败', error: error);
}
}
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async {
final session = state.session;
if (session.isSwitchingLens) {
return;
}
final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble();
_updateSession((s) => s.copyWith(isSwitchingLens: true));
try {
final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
final message = error.code == 'ZOOM_FAILED'
? '切换镜头失败,请重试'
: (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message));
} finally {
_updateSession(
(s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage),
);
}
}
/// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session;
if (session.isRecording ||
session.isStartingRecording ||
session.isSwitchingLens) {
return;
}
if (!session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试'));
return;
}
final displayName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename,
);
void updateZoomCapabilities({
required double zoomRatio,
required double minZoomRatio,
required double maxZoomRatio,
}) {
_updateSession(
(s) => s.copyWith(isStartingRecording: true, errorMessage: null),
(s) => s.copyWith(
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
errorMessage: null,
),
);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.session.hasDndAccess,
displayName: displayName,
);
_updateSession(
(s) => s.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
fileSaveFailed: false,
segmentOutputPaths: const [],
clearLastSaved: true,
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '开始录制失败'),
);
} finally {
_updateSession((s) => s.copyWith(isStartingRecording: false));
}
}
/// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async {
if (!state.session.isRecording || state.session.isSwitchingLens) return;
void markLensSwitching(bool switching) {
_updateSession((s) => s.copyWith(isSwitchingLens: switching));
}
try {
final result = await RecordingPlatform.stopRecording();
final fileFailed = !result.fileSaved;
final savedName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename,
);
void setZoomRatioValue(double ratio) {
_updateSession((s) => s.copyWith(zoomRatio: ratio, errorMessage: null));
}
void setError(String message) {
_updateSession((s) => s.copyWith(errorMessage: message));
}
void markStartingRecording() {
_updateSession(
(s) => s.copyWith(
isStartingRecording: true,
errorMessage: null,
clearStreamResult: true,
),
);
}
Future<void> markRecordingStarted(String streamUrl) async {
if (state.session.hasDndAccess) {
await RecordingPlatform.enableDoNotDisturb();
}
_recordingStartedAt = DateTime.now();
_elapsedTimer?.cancel();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
final startedAt = _recordingStartedAt;
if (startedAt == null) return;
final elapsed = DateTime.now().difference(startedAt).inMilliseconds;
_updateSession(
(s) => s.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? s.lastOutputPath,
lastSavedDisplayName: fileFailed ? null : savedName,
errorMessage: fileFailed
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null,
fileSaveFailed: fileFailed,
segmentOutputPaths: result.segmentOutputPaths,
status: RecordingStatus(
state: RecordingState.recording,
streamUrl: streamUrl,
elapsedMillis: elapsed,
),
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'),
);
} finally {
await restorePreview();
}
});
_updateSession(
(s) => s.copyWith(
status: RecordingStatus(
state: RecordingState.recording,
streamUrl: streamUrl,
),
lastStreamUrl: streamUrl,
isStartingRecording: false,
isTouchLocked: true,
errorMessage: null,
streamFailed: false,
clearStreamResult: true,
),
);
}
void markRecordingStartFailed(String message) {
_recordingStartedAt = null;
_elapsedTimer?.cancel();
_elapsedTimer = null;
_updateSession(
(s) => s.copyWith(
status: const RecordingStatus(state: RecordingState.previewing),
isStartingRecording: false,
errorMessage: message,
streamFailed: true,
),
);
}
Future<void> markRecordingStopped({String? errorMessage}) async {
final lastStreamUrl = state.session.lastStreamUrl;
final elapsed = _recordingStartedAt == null
? state.session.status.elapsedMillis
: DateTime.now().difference(_recordingStartedAt!).inMilliseconds;
_recordingStartedAt = null;
_elapsedTimer?.cancel();
_elapsedTimer = null;
await RecordingPlatform.disableDoNotDisturb();
_updateSession(
(s) => s.copyWith(
status: RecordingStatus(
state: RecordingState.previewing,
streamUrl: lastStreamUrl,
elapsedMillis: elapsed,
),
isStartingRecording: false,
errorMessage: errorMessage,
streamFinished: errorMessage == null,
streamFailed: errorMessage != null,
),
);
}
/// 切换录制中触屏锁定状态。
@@ -460,9 +255,9 @@ class RecordingViewModel extends Notifier<RecordingModel> {
_updateSession((s) => s.copyWith(isTouchLocked: locked));
}
/// 清除上次保存成功的录制结果标记。
/// 清除上次推流完成结果标记。
void clearSavedRecordingResult() {
_updateSession((s) => s.copyWith(clearLastSaved: true));
_updateSession((s) => s.copyWith(clearStreamResult: true));
}
/// 跳转系统勿扰/通知策略设置页。
@@ -489,22 +284,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
await RecordingPlatform.disposePreview();
await _statusSubscription?.cancel();
_statusSubscription = null;
_recordingStartedAt = null;
_elapsedTimer?.cancel();
_elapsedTimer = null;
state = state.copyWith(session: const RecordingSessionState());
}
/// 订阅原生层录制状态流并同步到 session。
Future<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
_updateSession((s) => s.copyWith(status: status));
});
}
/// Provider 销毁时取消状态流订阅。
Future<void> _dispose() async {
await _statusSubscription?.cancel();
_elapsedTimer?.cancel();
}
}
@@ -1,34 +1,22 @@
import 'dart:io';
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CameraPreviewWidget extends StatelessWidget {
const CameraPreviewWidget({super.key});
const CameraPreviewWidget({
super.key,
required this.controller,
required this.isReady,
});
final ApiVideoLiveStreamController controller;
final bool isReady;
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) {
return AndroidView(
viewType: 'recording-camera-preview',
layoutDirection: TextDirection.ltr,
creationParams: const <String, dynamic>{},
creationParamsCodec: const StandardMessageCodec(),
);
if (!isReady) {
return const ColoredBox(color: Colors.black);
}
if (Platform.isIOS) {
return UiKitView(
viewType: 'recording-camera-preview',
layoutDirection: TextDirection.ltr,
creationParams: const <String, dynamic>{},
creationParamsCodec: const StandardMessageCodec(),
);
}
return const ColoredBox(
color: Colors.black,
child: Center(child: Text('当前平台不支持相机预览')),
);
return ApiVideoCameraPreview(controller: controller, fit: BoxFit.cover);
}
}
@@ -1,51 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
/// 录制页顶部:Logo、粘贴赛事、赛事标题
/// 录制页顶部:Logo、赛事标题
class RecordHeaderWidget extends StatelessWidget {
const RecordHeaderWidget({
super.key,
required this.hasValidClipboardInfo,
this.eventTitle,
required this.isRecording,
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
final bool hasValidClipboardInfo;
final String? eventTitle;
final bool isRecording;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
bool get _showPasteButtons => !hasValidClipboardInfo && !isRecording;
bool get _showEventTitle => hasValidClipboardInfo;
bool get _showEventTitle => eventTitle?.trim().isNotEmpty == true;
Widget _buildAnimatedHeaderContent() {
if (_showEventTitle) {
return _HeaderEventTitleRow(
key: ValueKey('title-${eventTitle ?? ''}'),
title: eventTitle ?? '',
isRecording: isRecording,
onClearEventInfo: onClearEventInfo,
);
}
return const SizedBox.shrink(key: ValueKey('header-empty'));
}
void _mockCopyEventInfo() {
const strTemp =
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
Clipboard.setData(const ClipboardData(text: strTemp));
AppToast.show('模拟复制赛事信息成功');
}
@override
Widget build(BuildContext context) {
return SafeArea(
@@ -75,14 +56,6 @@ class RecordHeaderWidget extends StatelessWidget {
transitionBuilder: RecordContentTransition.builder,
child: _buildAnimatedHeaderContent(),
),
if (_showPasteButtons)
Align(
alignment: Alignment.centerRight,
child: _HeaderPasteActions(
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
),
),
],
),
),
@@ -95,16 +68,9 @@ class RecordHeaderWidget extends StatelessWidget {
}
class _HeaderEventTitleRow extends StatelessWidget {
const _HeaderEventTitleRow({
super.key,
required this.title,
required this.isRecording,
required this.onClearEventInfo,
});
const _HeaderEventTitleRow({super.key, required this.title});
final String title;
final bool isRecording;
final VoidCallback onClearEventInfo;
static TextStyle get _overlayTextStyle => TextStyle(
color: Colors.white,
@@ -135,87 +101,7 @@ class _HeaderEventTitleRow extends StatelessWidget {
),
),
),
!isRecording
? IconButton(
key: const ValueKey('clear-event-info'),
onPressed: onClearEventInfo,
icon: Assets.images.imageDelete.image(
width: 15.r,
height: 15.r,
fit: BoxFit.contain,
excludeFromSemantics: true,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
alignment: Alignment.centerRight,
tooltip: '删除',
)
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),
],
);
}
}
class _HeaderPasteActions extends StatelessWidget {
const _HeaderPasteActions({
required this.onMockCopy,
required this.onPasteEventInfo,
});
final VoidCallback onMockCopy;
final Future<void> Function() onPasteEventInfo;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// _HeaderActionButton(label: 'mock', onPressed: onMockCopy),
_HeaderActionButton(
label: '粘贴选手信息',
onPressed: () => onPasteEventInfo(),
icon: Assets.images.imageCopy.image(
width: 10.r,
height: 10.r,
fit: BoxFit.contain,
excludeFromSemantics: true,
),
),
],
);
}
}
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback onPressed;
final Widget? icon;
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: onPressed,
icon: icon ?? Icon(Icons.content_paste, size: 10.r),
label: Text(label),
style: TextButton.styleFrom(
minimumSize: Size.zero, // 取消 40dp 最小高度
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // 取消额外点击热区
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.5),
textStyle: TextStyle(fontSize: 10.sp),
padding: EdgeInsets.symmetric(horizontal: 7.r, vertical: 4.r),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.r),
side: const BorderSide(color: Colors.white30),
),
),
);
}
}
@@ -5,19 +5,19 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/core/utils/date_time_formatter.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
/// 左下角实时时钟与剪贴板地址
class ClipboardAddressClockChipWidget extends StatefulWidget {
const ClipboardAddressClockChipWidget({super.key, required this.address});
/// 左下角实时时钟与赛事场地信息。
class RecordingContextClockChipWidget extends StatefulWidget {
const RecordingContextClockChipWidget({super.key, required this.address});
final String address;
@override
State<ClipboardAddressClockChipWidget> createState() =>
_ClipboardAddressClockChipWidgetState();
State<RecordingContextClockChipWidget> createState() =>
_RecordingContextClockChipWidgetState();
}
class _ClipboardAddressClockChipWidgetState
extends State<ClipboardAddressClockChipWidget> {
class _RecordingContextClockChipWidgetState
extends State<RecordingContextClockChipWidget> {
Timer? _clockTimer;
static TextStyle get _textStyle => TextStyle(
@@ -42,10 +42,8 @@ class _ClipboardAddressClockChipWidgetState
super.dispose();
}
String get _nowText => DateTimeFormatter.format(
DateTime.now(),
pattern: 'yyyy-M-d-H:mm:ss',
);
String get _nowText =>
DateTimeFormatter.format(DateTime.now(), pattern: 'yyyy-M-d-H:mm:ss');
@override
Widget build(BuildContext context) {
@@ -71,7 +69,9 @@ class _ClipboardAddressClockChipWidgetState
key: ValueKey(widget.address),
style: _textStyle,
)
: const SizedBox.shrink(key: ValueKey('clipboard-address-empty')),
: const SizedBox.shrink(
key: ValueKey('recording-address-empty'),
),
),
],
),
@@ -3,8 +3,8 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/utils/rate_limiter.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_context_clock_chip.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart';
/// 录制页 HUD 层(状态提示、录制控制)
@@ -20,8 +20,8 @@ class RecordingHudWidget extends StatelessWidget {
required this.isStartingRecording,
required this.isSwitchingLens,
required this.isTouchLocked,
this.showClipboardHint = false,
this.clipboardAddress = '',
this.showContextHint = false,
this.contextAddress = '',
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
@@ -42,8 +42,8 @@ class RecordingHudWidget extends StatelessWidget {
final bool isStartingRecording;
final bool isSwitchingLens;
final bool isTouchLocked;
final bool showClipboardHint;
final String clipboardAddress;
final bool showContextHint;
final String contextAddress;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
@@ -117,12 +117,14 @@ class RecordingHudWidget extends StatelessWidget {
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: showClipboardHint
? ClipboardAddressClockChipWidget(
key: const ValueKey('clipboard-info'),
address: clipboardAddress,
child: showContextHint
? RecordingContextClockChipWidget(
key: const ValueKey('recording-context-info'),
address: contextAddress,
)
: const SizedBox.shrink(key: ValueKey('clipboard-info-hidden')),
: const SizedBox.shrink(
key: ValueKey('recording-context-info-hidden'),
),
),
),
if (isRecording)
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
/// 录制结束并保存到文件夹后的后续操作弹窗。
/// 推流录制结束后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
}) {
return RecordDialog.showDouble(
context,
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
title: '本轮比赛视频已提交 NAS 录制\n请选择后续录制信息',
leftText: '继续本轮',
rightText: '录制新轮',
onLeftPressed: onContinueRound,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
@@ -9,6 +10,19 @@ import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
class ScanQrCodePage extends StatefulWidget {
const ScanQrCodePage({super.key});
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
static const mockRecordingContext = RecordingContext(
eventTitle: '全国青少年无人机大赛',
matchName: '空中格斗赛',
group: '高中组',
venue: '场地 1',
time: '7月1日 12:00-15:00',
playerName: '蔡依婷vs夏志豪',
playerPhone: '',
);
@override
State<ScanQrCodePage> createState() => _AuthPageWidgetState();
}
@@ -40,7 +54,12 @@ class _AuthPageWidgetState extends State<ScanQrCodePage> {
if (result == null || result.isEmpty) return;
debugPrint('扫码结果: $result');
Future.delayed(const Duration(milliseconds: 1)).then((_) {
AppNavigator.push(const RecordingPage());
AppNavigator.push(
const RecordingPage(
recordingContext: ScanQrCodePage.mockRecordingContext,
streamUrl: ScanQrCodePage.mockRtmpUrl,
),
);
// AppNavigator.push(PushSteamTestWidget(rtmpUrl: result));
// AppNavigator.push(
// const WebviewPage(url: 'https://www.dronex.cc/'),