删除未使用的相机和记录类来重构记录功能;更新pubspec中的依赖项。Yaml为本地插件路径;简化MainActivity和相关类,以提高性能和可维护性。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user