更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。

This commit is contained in:
2026-06-05 11:44:51 +08:00
parent 4c5bf22638
commit 1e936bfc12
13 changed files with 567 additions and 387 deletions

View File

@@ -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();
}
}