删除未使用的相机和记录类来重构记录功能;更新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,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();
}
}