更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.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/utils/rate_limiter.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_session.dart';
|
||||
import 'package:recording_tool/features/recording/recording_display_name.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
|
||||
final recordingViewModelProvider =
|
||||
StateNotifierProvider<RecordingViewModel, RecordingModel>((ref) {
|
||||
return RecordingViewModel(ref);
|
||||
});
|
||||
NotifierProvider<RecordingViewModel, RecordingModel>(
|
||||
RecordingViewModel.new,
|
||||
);
|
||||
|
||||
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
||||
enum ClipboardReadResult {
|
||||
@@ -24,23 +31,33 @@ enum ClipboardReadResult {
|
||||
invalid,
|
||||
}
|
||||
|
||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
RecordingViewModel(this.ref)
|
||||
: super(
|
||||
RecordingModel(
|
||||
clipboardRecordingModel: ClipboardRecordingModel(
|
||||
title: '',
|
||||
address: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
final Ref ref;
|
||||
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
static const Duration _recordingActionInterval = Duration(milliseconds: 300);
|
||||
static const Object _startRecordingThrottleKey = 'recording.session.start';
|
||||
static const Object _stopRecordingThrottleKey = 'recording.session.stop';
|
||||
|
||||
static final _defaultClipboard = ClipboardRecordingModel(
|
||||
title: '',
|
||||
address: '',
|
||||
);
|
||||
|
||||
StreamSubscription<RecordingStatus>? _statusSubscription;
|
||||
final _rateLimit = RateLimitHub();
|
||||
|
||||
@override
|
||||
RecordingModel build() {
|
||||
ref.onDispose(_dispose);
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel: _defaultClipboard,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSession(
|
||||
RecordingSessionState Function(RecordingSessionState session) update,
|
||||
) {
|
||||
state = state.copyWith(session: update(state.session));
|
||||
}
|
||||
|
||||
/// 从剪切板获取小程序复制的录制信息。
|
||||
Future<ClipboardReadResult> getClipboardContent() async {
|
||||
try {
|
||||
@@ -95,4 +112,241 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
hasValidClipboardInfo: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> prepareSession() async {
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
||||
return;
|
||||
}
|
||||
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
..._galleryPermissions(),
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制'));
|
||||
return;
|
||||
}
|
||||
|
||||
final microphoneGranted =
|
||||
permissions[Permission.microphone]?.isGranted ?? false;
|
||||
final notificationsGranted = Platform.isAndroid
|
||||
? (permissions[Permission.notification]?.isGranted ?? false)
|
||||
: true;
|
||||
|
||||
final warnings = <String>[];
|
||||
if (Platform.isAndroid && !notificationsGranted) {
|
||||
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
|
||||
}
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
if (!_isGalleryPermissionGranted(permissions)) {
|
||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||
}
|
||||
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
hasDndAccess: hasDnd,
|
||||
isBatteryOptimizedIgnored: batteryIgnored,
|
||||
isMicrophoneGranted: microphoneGranted,
|
||||
notificationsGranted: notificationsGranted,
|
||||
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
|
||||
errorMessage: null,
|
||||
clearPermissionWarning: warnings.isEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
await _listenStatus();
|
||||
try {
|
||||
final status = await _initializePreviewWithRetry();
|
||||
_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<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');
|
||||
}
|
||||
|
||||
List<Permission> _galleryPermissions() {
|
||||
if (Platform.isIOS) {
|
||||
return [Permission.photosAddOnly, Permission.photos];
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return [Permission.videos, Permission.storage];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> permissions) {
|
||||
for (final permission in _galleryPermissions()) {
|
||||
if (permissions[permission]?.isGranted ?? false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return _galleryPermissions().isEmpty;
|
||||
}
|
||||
|
||||
bool _tryAcquireRecordingAction(Object key) {
|
||||
var executed = false;
|
||||
_rateLimit.throttle<void>(
|
||||
key: key,
|
||||
value: null,
|
||||
duration: _recordingActionInterval,
|
||||
options: const ThrottleOptions(leading: true, trailing: false),
|
||||
onCallback: (_) => executed = true,
|
||||
);
|
||||
return executed;
|
||||
}
|
||||
|
||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||
if (!_tryAcquireRecordingAction(_startRecordingThrottleKey)) return;
|
||||
|
||||
final session = state.session;
|
||||
if (!session.isPreviewReady ||
|
||||
session.isRecording ||
|
||||
session.isStartingRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
final displayName = recordingFileNameForPlatform(
|
||||
state.clipboardRecordingModel.filename,
|
||||
);
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(isStartingRecording: true, 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,
|
||||
gallerySaveFailed: false,
|
||||
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 (!_tryAcquireRecordingAction(_stopRecordingThrottleKey)) return;
|
||||
|
||||
if (!state.session.isRecording) return;
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.stopRecording();
|
||||
final galleryFailed = !result.gallerySaved;
|
||||
final savedName = recordingFileNameForPlatform(
|
||||
state.clipboardRecordingModel.filename,
|
||||
);
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||
errorMessage: galleryFailed
|
||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||
: null,
|
||||
gallerySaveFailed: galleryFailed,
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
_updateSession(
|
||||
(s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setTouchLocked(bool locked) {
|
||||
_updateSession((s) => s.copyWith(isTouchLocked: locked));
|
||||
}
|
||||
|
||||
void clearSavedRecordingResult() {
|
||||
_updateSession((s) => s.copyWith(clearLastSaved: true));
|
||||
}
|
||||
|
||||
Future<void> openDndSettings() =>
|
||||
RecordingPlatform.openNotificationPolicySettings();
|
||||
|
||||
Future<void> refreshDndAccess() async {
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
_updateSession((s) => s.copyWith(hasDndAccess: hasDnd));
|
||||
}
|
||||
|
||||
Future<void> openBatterySettings() =>
|
||||
RecordingPlatform.openBatteryOptimizationSettings();
|
||||
|
||||
Future<void> refreshBatteryOptimization() async {
|
||||
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||
_updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored));
|
||||
}
|
||||
|
||||
Future<void> teardown() async {
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
await RecordingPlatform.disposePreview();
|
||||
await _statusSubscription?.cancel();
|
||||
_statusSubscription = null;
|
||||
state = state.copyWith(session: const RecordingSessionState());
|
||||
}
|
||||
|
||||
Future<void> _listenStatus() async {
|
||||
await _statusSubscription?.cancel();
|
||||
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
|
||||
_updateSession((s) => s.copyWith(status: status));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _dispose() async {
|
||||
_rateLimit.clear();
|
||||
await _statusSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user