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