63 changed files with 3470 additions and 1060 deletions
@ -0,0 +1,144 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:logger/logger.dart'; |
||||
|
import '../../modules/login/controllers/googleLogin.dart'; |
||||
|
import '../../modules/login/controllers/facebookLogin.dart'; |
||||
|
import '../services/network/api.dart'; |
||||
|
|
||||
|
/// 账号管理服务 |
||||
|
/// |
||||
|
/// 负责处理所有与用户账号相关的操作,包括登出和账号删除 |
||||
|
class AccountService extends GetxService { |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
final logger = Logger(); |
||||
|
|
||||
|
/// 初始化服务 |
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
logger.d('AccountService 已初始化'); |
||||
|
} |
||||
|
|
||||
|
/// 清理本地用户数据 |
||||
|
/// |
||||
|
/// 返回操作是否成功 |
||||
|
Future<bool> clearUserData() async { |
||||
|
try { |
||||
|
// 清理所有存储 |
||||
|
await _storage.erase(); |
||||
|
// 或者逐个清理关键存储项 |
||||
|
await _storage.write("logintoken", null); |
||||
|
await _storage.write("user_info", null); |
||||
|
await _storage.write("local_avatar", null); |
||||
|
logger.d('用户数据清理成功'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
logger.e('用户数据清理失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 退出第三方登录 |
||||
|
/// |
||||
|
/// 返回操作是否成功 |
||||
|
Future<bool> logoutThirdParty() async { |
||||
|
try { |
||||
|
final google = Google(); |
||||
|
final facebook = Facebook(); |
||||
|
await Future.wait([ |
||||
|
google.signOutGoogle(), |
||||
|
facebook.signOutFacebook(), |
||||
|
]); |
||||
|
logger.d('第三方登录退出成功'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
logger.e('第三方登录退出失败: $e'); |
||||
|
// 返回true,因为这不应阻止整体登出流程 |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 用户登出 |
||||
|
/// |
||||
|
/// 清理本地数据并退出第三方登录 |
||||
|
/// 返回操作结果,包含成功状态和错误信息 |
||||
|
Future<AccountOperationResult> signOut() async { |
||||
|
try { |
||||
|
// 清理本地数据 |
||||
|
final dataCleaned = await clearUserData(); |
||||
|
if (!dataCleaned) { |
||||
|
return AccountOperationResult( |
||||
|
success: false, |
||||
|
errorMessage: '清理用户数据失败', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 退出第三方登录 |
||||
|
await logoutThirdParty(); |
||||
|
|
||||
|
return AccountOperationResult( |
||||
|
success: true, |
||||
|
message: '已退出登录', |
||||
|
); |
||||
|
} catch (e) { |
||||
|
logger.e('登出过程发生错误: $e'); |
||||
|
return AccountOperationResult( |
||||
|
success: false, |
||||
|
errorMessage: '退出登录失败,请重试', |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 删除用户账号 |
||||
|
/// |
||||
|
/// 调用API删除账号,然后清理本地数据 |
||||
|
/// 返回操作结果,包含成功状态和错误信息 |
||||
|
Future<AccountOperationResult> deleteAccount() async { |
||||
|
try { |
||||
|
// 调用API删除账号 |
||||
|
final response = await Api.deleteUser(); |
||||
|
if (response == null) { |
||||
|
return AccountOperationResult( |
||||
|
success: false, |
||||
|
errorMessage: '删除用户数据失败', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 清理本地数据 |
||||
|
final dataCleaned = await clearUserData(); |
||||
|
if (!dataCleaned) { |
||||
|
return AccountOperationResult( |
||||
|
success: false, |
||||
|
errorMessage: '清理用户数据失败', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 退出第三方登录 |
||||
|
await logoutThirdParty(); |
||||
|
|
||||
|
return AccountOperationResult( |
||||
|
success: true, |
||||
|
message: '账号已被永久删除', |
||||
|
); |
||||
|
} catch (e) { |
||||
|
logger.e('删除账号过程发生错误: $e'); |
||||
|
return AccountOperationResult( |
||||
|
success: false, |
||||
|
errorMessage: '删除账号失败,请重试', |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 账号操作结果 |
||||
|
class AccountOperationResult { |
||||
|
final bool success; |
||||
|
final String? message; |
||||
|
final String? errorMessage; |
||||
|
|
||||
|
AccountOperationResult({ |
||||
|
required this.success, |
||||
|
this.message, |
||||
|
this.errorMessage, |
||||
|
}); |
||||
|
} |
||||
@ -1,5 +1,5 @@ |
|||||
class NWApi { |
class NWApi { |
||||
// static const baseApi = "https://www.deapsound.com"; |
// static const baseApi = "https://www.deapsound.com"; |
||||
static const baseApi = "https://api.deapsound.com"; |
static const baseApi = "https://api.deapsound.com"; |
||||
//static const baseApi = "https://api.ideapsound.com"; |
// static const baseApi = "https://api.ideapsound.com"; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,140 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'dart:math' as math; |
||||
|
|
||||
|
/// 环形进度条播放按钮组件 |
||||
|
class CircularProgressButton extends StatelessWidget { |
||||
|
final bool isPlaying; |
||||
|
final double progress; // 0.0 - 1.0 |
||||
|
final VoidCallback onPlayPause; |
||||
|
final Function(double)? onSeek; // 点击进度条跳转回调 |
||||
|
final double size; |
||||
|
final Color primaryColor; |
||||
|
final Color backgroundColor; |
||||
|
final double strokeWidth; |
||||
|
|
||||
|
const CircularProgressButton({ |
||||
|
Key? key, |
||||
|
required this.isPlaying, |
||||
|
required this.progress, |
||||
|
required this.onPlayPause, |
||||
|
this.onSeek, |
||||
|
this.size = 36.0, |
||||
|
required this.primaryColor, |
||||
|
this.backgroundColor = Colors.grey, |
||||
|
this.strokeWidth = 2.0, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return GestureDetector( |
||||
|
onTapDown: (details) { |
||||
|
if (onSeek != null) { |
||||
|
// 计算点击位置对应的进度 |
||||
|
final center = Offset(size / 2, size / 2); |
||||
|
final tapPosition = details.localPosition; |
||||
|
final dx = tapPosition.dx - center.dx; |
||||
|
final dy = tapPosition.dy - center.dy; |
||||
|
|
||||
|
// 计算角度 (从顶部开始,顺时针) |
||||
|
double angle = math.atan2(dy, dx) + math.pi / 2; |
||||
|
if (angle < 0) angle += 2 * math.pi; |
||||
|
|
||||
|
// 转换为进度值 (0.0 - 1.0) |
||||
|
final seekProgress = angle / (2 * math.pi); |
||||
|
|
||||
|
// 检查点击是否在圆环区域内 |
||||
|
final distance = math.sqrt(dx * dx + dy * dy); |
||||
|
final innerRadius = (size / 2) - strokeWidth - 4; |
||||
|
final outerRadius = (size / 2) + 4; |
||||
|
|
||||
|
if (distance >= innerRadius && distance <= outerRadius) { |
||||
|
onSeek!(seekProgress); |
||||
|
} else if (distance < innerRadius) { |
||||
|
// 点击中心区域,播放/暂停 |
||||
|
onPlayPause(); |
||||
|
} |
||||
|
} else { |
||||
|
onPlayPause(); |
||||
|
} |
||||
|
}, |
||||
|
child: SizedBox( |
||||
|
width: size, |
||||
|
height: size, |
||||
|
child: Stack( |
||||
|
alignment: Alignment.center, |
||||
|
children: [ |
||||
|
// 背景圆环 |
||||
|
CustomPaint( |
||||
|
size: Size(size, size), |
||||
|
painter: _CircularProgressPainter( |
||||
|
progress: 1.0, |
||||
|
color: backgroundColor.withValues(alpha: 0.3), |
||||
|
strokeWidth: strokeWidth, |
||||
|
), |
||||
|
), |
||||
|
// 进度圆环 |
||||
|
CustomPaint( |
||||
|
size: Size(size, size), |
||||
|
painter: _CircularProgressPainter( |
||||
|
progress: progress, |
||||
|
color: primaryColor, |
||||
|
strokeWidth: strokeWidth, |
||||
|
), |
||||
|
), |
||||
|
// 播放/暂停按钮 |
||||
|
Icon( |
||||
|
isPlaying ? Icons.pause : Icons.play_arrow, |
||||
|
color: primaryColor, |
||||
|
size: size * 0.5, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 环形进度条绘制器 |
||||
|
class _CircularProgressPainter extends CustomPainter { |
||||
|
final double progress; |
||||
|
final Color color; |
||||
|
final double strokeWidth; |
||||
|
|
||||
|
_CircularProgressPainter({ |
||||
|
required this.progress, |
||||
|
required this.color, |
||||
|
required this.strokeWidth, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
void paint(Canvas canvas, Size size) { |
||||
|
final center = Offset(size.width / 2, size.height / 2); |
||||
|
final radius = (size.width / 2) - strokeWidth / 2; |
||||
|
|
||||
|
final paint = Paint() |
||||
|
..color = color |
||||
|
..strokeWidth = strokeWidth |
||||
|
..style = PaintingStyle.stroke |
||||
|
..strokeCap = StrokeCap.round; |
||||
|
|
||||
|
// 绘制圆弧,从顶部开始 (-π/2),顺时针绘制 |
||||
|
const startAngle = -math.pi / 2; |
||||
|
final sweepAngle = 2 * math.pi * progress; |
||||
|
|
||||
|
canvas.drawArc( |
||||
|
Rect.fromCircle(center: center, radius: radius), |
||||
|
startAngle, |
||||
|
sweepAngle, |
||||
|
false, |
||||
|
paint, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) { |
||||
|
return oldDelegate is! _CircularProgressPainter || |
||||
|
oldDelegate.progress != progress || |
||||
|
oldDelegate.color != color || |
||||
|
oldDelegate.strokeWidth != strokeWidth; |
||||
|
} |
||||
|
} |
||||
@ -1,254 +1,419 @@ |
|||||
// meeting_record_controller.dart |
// meeting_record_controller.dart |
||||
import 'dart:async'; |
import 'dart:async'; |
||||
|
import 'dart:io'; |
||||
import 'dart:math'; |
import 'dart:math'; |
||||
import 'dart:typed_data'; |
import 'package:deep_voice/core/utils/logger.dart'; |
||||
|
import 'package:deep_voice/core/utils/permission_util.dart'; |
||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import 'package:path_provider/path_provider.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
import '../../../data/services/asr_service.dart'; |
import '../../../data/services/asr_service.dart'; |
||||
import '../../../data/services/ble_manager.dart'; |
import '../../../data/services/ble_manager.dart'; |
||||
|
|
||||
// 新增计时器状态枚举 |
/// Enumerates the possible states of the recording timer |
||||
enum TimerStatus { |
enum TimerStatus { |
||||
stopped, // 已停止/未开始 |
stopped, // Timer is inactive |
||||
running, // 运行中 |
running, // Timer is actively counting |
||||
paused, // 已暂停 |
paused, // Timer is paused |
||||
} |
} |
||||
|
|
||||
/// 控制器:处理录音状态、波形、文本转录等逻辑 |
/// Controller for meeting recording screen |
||||
|
/// Handles recording state, audio processing, transcription, and UI interactions |
||||
class MeetingRecordController extends GetxController |
class MeetingRecordController extends GetxController |
||||
with SingleGetTickerProviderMixin { |
with SingleGetTickerProviderMixin { |
||||
//语言服务 |
// Services |
||||
final AsrService _asrService = Get.find<AsrService>(); |
final AsrService _asrService = Get.find<AsrService>(); |
||||
//语言服务监听 |
final BleManager _bleManager = Get.find<BleManager>(); |
||||
|
|
||||
|
// Stream subscriptions |
||||
StreamSubscription? _recognitionSubscription; |
StreamSubscription? _recognitionSubscription; |
||||
//是否开启识别 |
|
||||
|
// Recording state |
||||
|
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs; |
||||
|
final RxInt seconds = 0.obs; |
||||
|
final RxInt audioType = 0.obs; // 0=Live, 1=Media, 2=Call |
||||
|
final RxBool isMarked = false.obs; |
||||
|
final RxBool showTextContent = false.obs; |
||||
final RxBool isVoiceRecognitionActive = false.obs; |
final RxBool isVoiceRecognitionActive = false.obs; |
||||
// 音频输入源 |
final RxList<double> waveData = <double>[].obs; |
||||
bool _audioSourceType = false; |
|
||||
|
|
||||
//识别文本 |
// Transcription content |
||||
RxString intermediateContent = ''.obs; |
final RxString intermediateContent = ''.obs; |
||||
RxString finalContent = ''.obs; |
final RxString finalContent = ''.obs; |
||||
// final RxBool isRecording = false.obs; // 是否正在录音 |
|
||||
// 蓝牙服务 |
|
||||
final _bleManager = Get.find<BleManager>(); |
|
||||
final RxInt seconds = 0.obs; // 录音秒数 |
|
||||
final RxInt audioTypes = 0.obs; // 音频类型 |
|
||||
final RxBool isMarked = false.obs; // 是否标记重点 |
|
||||
final RxList<double> waveData = <double>[].obs; // 波形图数据 |
|
||||
RxBool showTextContent = false.obs; // 控制文本显示 |
|
||||
late TabController tabController; |
|
||||
Timer? _timer; |
|
||||
int _currentPosition = 0; // 当前播放位置(用于波形图动画) |
|
||||
|
|
||||
// 新增计时器状态变量 |
// File management |
||||
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs; |
final RxString fileName = "初始文件名".obs; |
||||
int _elapsedMilliseconds = 0; // 累计毫秒数 |
final RxString newName = "".obs; |
||||
int? _lastStartTime; // 上次开始计时的时间戳 |
final RxBool isEditingTitle = false.obs; |
||||
|
late TextEditingController titleEditingController; |
||||
|
late FocusNode titleFocusNode; |
||||
|
final Future<Directory> appDocDir = getApplicationDocumentsDirectory(); |
||||
|
|
||||
|
// Timing variables |
||||
|
Timer? _timer; |
||||
|
int _elapsedMilliseconds = 0; |
||||
|
int? _lastStartTime; |
||||
|
int _currentPosition = 0; |
||||
|
bool _audioSourceType = false; |
||||
|
late TabController tabController; |
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
_asrService.initialize(); |
_initializeComponents(); |
||||
tabController = TabController(length: 2, vsync: this); |
_setupFromArguments(); |
||||
generateWaveData(); |
generateWaveData(); |
||||
// 移除初始化的 startTimer() 调用 |
|
||||
} |
} |
||||
|
|
||||
@override |
@override |
||||
void onClose() { |
void onClose() { |
||||
_timer?.cancel(); |
_cleanupResources(); |
||||
_asrService.dispose(); |
|
||||
|
|
||||
tabController.dispose(); |
|
||||
super.onClose(); |
super.onClose(); |
||||
} |
} |
||||
|
|
||||
/// 启动/继续计时器 |
// 请求录音权限 |
||||
void startTimer() { |
Future<bool> _requestRecordPermission() async { |
||||
if (timerStatus.value != TimerStatus.running) { |
try { |
||||
timerStatus.value = TimerStatus.running; |
final bool granted = await PermissionUtil.instance.requestPermission( |
||||
_lastStartTime = DateTime.now().millisecondsSinceEpoch; |
permissionType: Permission.microphone, |
||||
|
permissionName: "录音", |
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (timer) { |
explanationText: "我们需要访问麦克风权限,以便您录制音频内容。", |
||||
if (timerStatus.value == TimerStatus.running) { |
permanentDenialText: "检测到您已永久拒绝录音权限。如需继续使用此功能,请前往应用设置中手动开启麦克风权限。", |
||||
// 计算真实经过的时间(毫秒) |
); |
||||
final currentTime = DateTime.now().millisecondsSinceEpoch; |
|
||||
_elapsedMilliseconds += currentTime - _lastStartTime!; |
|
||||
_lastStartTime = currentTime; |
|
||||
|
|
||||
// 转换为秒(四舍五入) |
if (granted) { |
||||
seconds.value = (_elapsedMilliseconds / 1000).round(); |
Logger.d("Permission", "用户授予了录音权限"); |
||||
update(); |
return true; |
||||
} |
} else { |
||||
}); |
Logger.d("Permission", "用户未授予录音权限"); |
||||
|
return false; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.e("Permission", "请求录音权限时出错: $e"); |
||||
|
return false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 暂停计时器 |
/// Initializes UI controllers and focus nodes |
||||
void pauseTimer() { |
void _initializeComponents() { |
||||
if (timerStatus.value == TimerStatus.running) { |
tabController = TabController(length: 2, vsync: this); |
||||
timerStatus.value = TimerStatus.paused; |
titleEditingController = TextEditingController(text: fileName.value); |
||||
_timer?.cancel(); |
titleFocusNode = FocusNode(); |
||||
_timer = null; |
titleFocusNode.addListener(_handleTitleFocusChange); |
||||
|
} |
||||
|
|
||||
// 更新累计时间 |
/// Sets up initial state from navigation arguments |
||||
final currentTime = DateTime.now().millisecondsSinceEpoch; |
void _setupFromArguments() { |
||||
_elapsedMilliseconds += currentTime - _lastStartTime!; |
final Map<String, dynamic> args = Get.arguments ?? {}; |
||||
_lastStartTime = null; |
audioType.value = args['audioTypes'] ?? 0; |
||||
|
|
||||
|
// Set default filename based on audio type |
||||
|
switch (audioType.value) { |
||||
|
case 0: |
||||
|
fileName.value = "现场录音"; |
||||
|
break; |
||||
|
case 1: |
||||
|
fileName.value = "音、视频录音"; |
||||
|
break; |
||||
|
case 2: |
||||
|
fileName.value = "通话录音"; |
||||
|
break; |
||||
} |
} |
||||
|
newName.value = fileName.value; |
||||
} |
} |
||||
|
|
||||
/// 重置计时器 |
/// Cleans up resources when controller is disposed |
||||
void resetTimer() { |
void _cleanupResources() { |
||||
_timer?.cancel(); |
_timer?.cancel(); |
||||
_timer = null; |
_recognitionSubscription?.cancel(); |
||||
_elapsedMilliseconds = 0; |
titleEditingController.dispose(); |
||||
_lastStartTime = null; |
titleFocusNode.dispose(); |
||||
seconds.value = 0; |
_bleManager.closeCodec(); |
||||
timerStatus.value = TimerStatus.stopped; |
_asrService.stopContinuousRecognition(); |
||||
|
_asrService.stopRecord(true); |
||||
|
} |
||||
|
|
||||
|
/// Handles focus changes in title text field |
||||
|
void _handleTitleFocusChange() { |
||||
|
if (!titleFocusNode.hasFocus && isEditingTitle.value) { |
||||
|
saveTitleChange(); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
void toggleTextDisplay() { |
/// Starts title editing mode |
||||
showTextContent.toggle(); // 点击按钮切换显示状态 |
void startTitleEditing() { |
||||
|
isEditingTitle.value = true; |
||||
|
titleEditingController.text = fileName.value; |
||||
|
newName.value = fileName.value; |
||||
|
Future.delayed(const Duration(milliseconds: 100), () { |
||||
|
titleFocusNode.requestFocus(); |
||||
|
}); |
||||
} |
} |
||||
|
|
||||
/// 切换录音状态 |
/// Saves new title and exits editing mode |
||||
Future<void> toggleRecording() async { |
Future<void> saveTitleChange() async { |
||||
if (timerStatus.value == TimerStatus.stopped) { |
isEditingTitle.value = true; |
||||
await _startRecording(); |
if (isEditingTitle.value) { |
||||
} else if (timerStatus.value == TimerStatus.running) { |
newName.value = titleEditingController.text; |
||||
await _pauseRecording(); |
isEditingTitle.value = false; |
||||
} else if (timerStatus.value == TimerStatus.paused) { |
// Actual file renaming happens during saveRecording() |
||||
await _resumeRecording(); |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 生成模拟波形图数据 |
/// Toggles visibility of transcribed text content |
||||
void generateWaveData() { |
void toggleTextDisplay() => showTextContent.toggle(); |
||||
waveData.value = List.generate(120, (i) { |
|
||||
final rand = (i % 20) / 20; |
/// Toggles important mark status |
||||
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25; |
void toggleMarked() => isMarked.toggle(); |
||||
return baseHeight + rand * 10; |
|
||||
}); |
/// Formats seconds to MM:SS display format |
||||
|
String formatTime(int seconds) { |
||||
|
return '${(seconds ~/ 60).toString().padLeft(2, '0')}' |
||||
|
':${(seconds % 60).toString().padLeft(2, '0')}'; |
||||
} |
} |
||||
|
|
||||
// 处理语音识别事件 |
/// Starts/resumes recording based on current state |
||||
void _handleRecognitionEvent(RecognitionEvent event) { |
Future<void> toggleRecording() async { |
||||
switch (event.type) { |
// 等待权限请求完成并获取结果 |
||||
case RecognitionEventType.intermediateResult: |
final bool granted = await _requestRecordPermission(); |
||||
print( |
|
||||
"_handleRecognitionEvent,intermediateResult,event.text=${event.text}"); |
// 如果权限被拒绝,直接返回 |
||||
intermediateContent.value = event.text; |
if (!granted) { |
||||
|
Logger.d("Permission", "录音权限被拒绝,无法开始录音"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 根据当前计时器状态执行相应操作 |
||||
|
switch (timerStatus.value) { |
||||
|
case TimerStatus.stopped: |
||||
|
await _startRecording(); |
||||
break; |
break; |
||||
case RecognitionEventType.finalResult: |
case TimerStatus.running: |
||||
print("_handleRecognitionEvent,finalResult,event.text=${event.text}"); |
await _pauseRecording(); |
||||
finalContent.value += _spliceText(event); |
|
||||
intermediateContent.value = ''; |
|
||||
//_originaBytes.add(event.audio!); |
|
||||
break; |
break; |
||||
default: |
case TimerStatus.paused: |
||||
// 不做任何处理 |
await _resumeRecording(); |
||||
break; |
break; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
String _spliceText(RecognitionEvent event) { |
/// Starts recording session |
||||
String eventText = event.text; |
|
||||
String text = ''; |
|
||||
if (finalContent.value.isNotEmpty) { |
|
||||
if (event.role != '') { |
|
||||
final punctuations = { |
|
||||
',', |
|
||||
'。', |
|
||||
'!', |
|
||||
'?', |
|
||||
';', |
|
||||
':', |
|
||||
',', |
|
||||
'.', |
|
||||
':', |
|
||||
';', |
|
||||
'!', |
|
||||
'?' |
|
||||
}; |
|
||||
final firstChar = eventText[0]; |
|
||||
if (punctuations.contains(firstChar)) { |
|
||||
text += '$firstChar\n'; |
|
||||
eventText = eventText.substring(1); |
|
||||
} else { |
|
||||
text += '\n'; |
|
||||
} |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
} else { |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
text += eventText; |
|
||||
return text; |
|
||||
} |
|
||||
|
|
||||
Future<void> _startRecording() async { |
Future<void> _startRecording() async { |
||||
// 开始录音 |
// Initialize speech recognition |
||||
final recognitionStream = |
final recognitionStream = |
||||
await _asrService.startContinuousRecognition(_audioSourceType); |
await _asrService.startContinuousRecognition(_audioSourceType); |
||||
_recognitionSubscription?.cancel(); |
_recognitionSubscription?.cancel(); |
||||
_recognitionSubscription = |
_recognitionSubscription = |
||||
recognitionStream.listen(_handleRecognitionEvent); |
recognitionStream.listen(_handleRecognitionEvent); |
||||
await _asrService.enableRecord("录音"); |
|
||||
|
|
||||
|
// Prepare storage directory |
||||
|
final appDir = await appDocDir; |
||||
|
final dir = Directory("${appDir.path}/MeetingAudio/"); |
||||
|
if (!await dir.exists()) await dir.create(recursive: true); |
||||
|
|
||||
|
// Generate timestamped filename |
||||
|
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); |
||||
|
final fullFileName = "${fileName.value}_$formattedTime"; |
||||
|
|
||||
|
// Start audio recording |
||||
|
await _asrService.enableRecord("${dir.path}/$fullFileName.wav"); |
||||
|
|
||||
|
// Update state |
||||
|
fileName.value = fullFileName; |
||||
|
newName.value = fullFileName; |
||||
|
titleEditingController.text = fullFileName; |
||||
|
|
||||
|
// Start hardware components |
||||
|
_startHardwareServices(); |
||||
startTimer(); |
startTimer(); |
||||
|
} |
||||
|
|
||||
// 蓝牙处理 |
/// Activates appropriate hardware service based on audio type |
||||
if (audioTypes == 0) { |
void _startHardwareServices() { |
||||
_bleManager.openEncoder(); |
switch (audioType.value) { |
||||
} else if (audioTypes == 1) { |
case 0: |
||||
_bleManager.openDecoder(); |
_bleManager.openEncoder(); |
||||
} else if (audioTypes == 2) { |
break; |
||||
_bleManager.openA2DPDecoder(); |
case 1: |
||||
|
_bleManager.openDecoder(); |
||||
|
break; |
||||
|
case 2: |
||||
|
_bleManager.openA2DPDecoder(); |
||||
|
break; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
|
/// Pauses current recording session |
||||
Future<void> _pauseRecording() async { |
Future<void> _pauseRecording() async { |
||||
// 暂停录音 |
|
||||
await _asrService.stopContinuousRecognition(); |
await _asrService.stopContinuousRecognition(); |
||||
await _asrService.pauseRecord(); |
await _asrService.pauseRecord(); |
||||
pauseTimer(); |
pauseTimer(); |
||||
//await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法 |
|
||||
} |
} |
||||
|
|
||||
|
/// Resumes paused recording session |
||||
Future<void> _resumeRecording() async { |
Future<void> _resumeRecording() async { |
||||
// 开始录音 |
|
||||
final recognitionStream = |
final recognitionStream = |
||||
await _asrService.startContinuousRecognition(_audioSourceType); |
await _asrService.startContinuousRecognition(_audioSourceType); |
||||
_recognitionSubscription?.cancel(); |
_recognitionSubscription?.cancel(); |
||||
_recognitionSubscription = |
_recognitionSubscription = |
||||
recognitionStream.listen(_handleRecognitionEvent); |
recognitionStream.listen(_handleRecognitionEvent); |
||||
startTimer(); |
startTimer(); |
||||
|
|
||||
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法 |
|
||||
} |
} |
||||
|
|
||||
/// 切换是否标记为重点 |
/// Handles speech recognition events |
||||
void toggleMarked() { |
void _handleRecognitionEvent(RecognitionEvent event) { |
||||
isMarked.toggle(); |
switch (event.type) { |
||||
|
case RecognitionEventType.intermediateResult: |
||||
|
intermediateContent.value = event.text; |
||||
|
break; |
||||
|
case RecognitionEventType.finalResult: |
||||
|
finalContent.value += _formatTranscript(event); |
||||
|
intermediateContent.value = ''; |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
} |
} |
||||
|
|
||||
/// 格式化时间为 mm:ss |
/// Formats transcript with speaker identification |
||||
String formatTime(int seconds) { |
String _formatTranscript(RecognitionEvent event) { |
||||
// 实现你的时间格式化逻辑 |
final buffer = StringBuffer(); |
||||
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; |
|
||||
|
if (finalContent.value.isNotEmpty) { |
||||
|
if (event.role.isNotEmpty) { |
||||
|
// Add line break before new speaker |
||||
|
if (_isPunctuation(event.text[0])) { |
||||
|
buffer.write('${event.text[0]}\n'); |
||||
|
buffer.write('说话人${event.role}:'); |
||||
|
buffer.write(event.text.substring(1)); |
||||
|
} else { |
||||
|
buffer.write('\n说话人${event.role}:'); |
||||
|
buffer.write(event.text); |
||||
|
} |
||||
|
} else { |
||||
|
buffer.write(event.text); |
||||
|
} |
||||
|
} else { |
||||
|
buffer.write('说话人${event.role}:${event.text}'); |
||||
|
} |
||||
|
|
||||
|
return buffer.toString(); |
||||
} |
} |
||||
|
|
||||
/// 游标在波形图中的位置(0~1) |
/// Checks if character is Chinese/English punctuation |
||||
double get cursorPosition => _currentPosition / waveData.length; |
bool _isPunctuation(String char) { |
||||
|
const punctuations = { |
||||
|
',', |
||||
|
'。', |
||||
|
'!', |
||||
|
'?', |
||||
|
';', |
||||
|
':', |
||||
|
',', |
||||
|
'.', |
||||
|
':', |
||||
|
';', |
||||
|
'!', |
||||
|
'?' |
||||
|
}; |
||||
|
return punctuations.contains(char); |
||||
|
} |
||||
|
|
||||
/// 保存录音(目前模拟为返回上一页) |
/// Saves recording and cleans up resources |
||||
Future<void> saveRecording() async { |
Future<void> saveRecording() async { |
||||
resetTimer(); // 重置计时器 |
resetTimer(); |
||||
_bleManager.closeCodec(); |
_bleManager.closeCodec(); |
||||
await _asrService.stopRecord(); |
await _asrService.stopRecord(true); |
||||
|
|
||||
|
// Rename file if title was edited |
||||
|
if (fileName.value != newName.value) { |
||||
|
final appDir = await appDocDir; |
||||
|
final dir = Directory("${appDir.path}/MeetingAudio/"); |
||||
|
if (!await dir.exists()) await dir.create(recursive: true); |
||||
|
await _asrService.renameFile( |
||||
|
"${dir.path}/${fileName.value}.wav", |
||||
|
newName.value, |
||||
|
); |
||||
|
} |
||||
|
|
||||
await _asrService.stopContinuousRecognition(); |
await _asrService.stopContinuousRecognition(); |
||||
Get.back(); |
_resetToDefaultState(); |
||||
|
} |
||||
|
|
||||
|
/// Resets controller to initial state |
||||
|
void _resetToDefaultState() { |
||||
|
switch (audioType.value) { |
||||
|
case 0: |
||||
|
fileName.value = "现场录音"; |
||||
|
break; |
||||
|
case 1: |
||||
|
fileName.value = "音、视频录音"; |
||||
|
break; |
||||
|
case 2: |
||||
|
fileName.value = "通话录音"; |
||||
|
break; |
||||
|
} |
||||
|
newName.value = fileName.value; |
||||
|
finalContent.value = ''; |
||||
|
intermediateContent.value = ''; |
||||
|
isMarked.value = false; |
||||
|
} |
||||
|
|
||||
|
/// TIMER CONTROL SECTION /// |
||||
|
|
||||
|
/// Starts or resumes timer |
||||
|
void startTimer() { |
||||
|
if (timerStatus.value != TimerStatus.running) { |
||||
|
timerStatus.value = TimerStatus.running; |
||||
|
_lastStartTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
|
||||
|
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) { |
||||
|
if (timerStatus.value != TimerStatus.running) return; |
||||
|
|
||||
|
final currentTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
_elapsedMilliseconds += currentTime - _lastStartTime!; |
||||
|
_lastStartTime = currentTime; |
||||
|
seconds.value = (_elapsedMilliseconds / 1000).round(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Pauses active timer |
||||
|
void pauseTimer() { |
||||
|
if (timerStatus.value == TimerStatus.running) { |
||||
|
timerStatus.value = TimerStatus.paused; |
||||
|
_timer?.cancel(); |
||||
|
_timer = null; |
||||
|
|
||||
|
final currentTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
_elapsedMilliseconds += currentTime - _lastStartTime!; |
||||
|
_lastStartTime = null; |
||||
|
} |
||||
} |
} |
||||
|
|
||||
|
/// Resets timer to initial state |
||||
|
void resetTimer() { |
||||
|
_timer?.cancel(); |
||||
|
_timer = null; |
||||
|
_elapsedMilliseconds = 0; |
||||
|
_lastStartTime = null; |
||||
|
seconds.value = 0; |
||||
|
timerStatus.value = TimerStatus.stopped; |
||||
|
} |
||||
|
|
||||
|
/// UI HELPERS SECTION /// |
||||
|
|
||||
|
/// Generates mock waveform data for visualization |
||||
|
void generateWaveData() { |
||||
|
waveData.value = List.generate(120, (i) { |
||||
|
final rand = (i % 20) / 20; |
||||
|
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25; |
||||
|
return baseHeight + rand * 10; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// Current playback position in waveform (0-1) |
||||
|
double get cursorPosition => _currentPosition / waveData.length; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/music_playlist_controller.dart'; |
||||
|
|
||||
|
class MusicPlaylistBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<MusicPlaylistController>( |
||||
|
() => MusicPlaylistController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
class MusicPlaylistController extends GetxController { |
||||
|
// 歌单数据 |
||||
|
final playlist = <Map<String, dynamic>>[].obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// 从路由参数获取歌单数据 |
||||
|
final List<Map<String, dynamic>> playlistData = Get.arguments ?? []; |
||||
|
playlist.assignAll(playlistData); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,203 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../agent/controllers/agent_controller.dart'; |
||||
|
import '../../agent/views/circular_progress_button.dart'; |
||||
|
|
||||
|
class MusicPlaylistView extends StatelessWidget { |
||||
|
const MusicPlaylistView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final controller = Get.find<AgentController>(); |
||||
|
final List<Map<String, dynamic>> playlist = Get.arguments ?? []; |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('音乐播放列表'), |
||||
|
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
foregroundColor: isDarkMode ? Colors.white : Colors.black, |
||||
|
elevation: 0, |
||||
|
), |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
body: playlist.isEmpty |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.music_off, |
||||
|
size: 64, |
||||
|
color: Colors.grey[400], |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
'暂无音乐', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ListView.builder( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
itemCount: playlist.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final item = playlist[index]; |
||||
|
final id = item["id"]?.toString() ?? ""; |
||||
|
final name = item["name"] ?? "未知歌曲"; |
||||
|
final url = item["url"] ?? ""; |
||||
|
final singer = item["singer"] ?? item["sgener"] ?? "未知歌手"; |
||||
|
final image = item["image"] ?? ""; |
||||
|
|
||||
|
return _buildMusicItem( |
||||
|
context, |
||||
|
controller, |
||||
|
id, |
||||
|
name, |
||||
|
url, |
||||
|
singer, |
||||
|
image, |
||||
|
isDarkMode, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMusicItem( |
||||
|
BuildContext context, |
||||
|
AgentController controller, |
||||
|
String id, |
||||
|
String name, |
||||
|
String url, |
||||
|
String singer, |
||||
|
String image, |
||||
|
bool isDarkMode, |
||||
|
) { |
||||
|
final song = { |
||||
|
'id': id, |
||||
|
'title': name, |
||||
|
'url': url, |
||||
|
'artist': singer, |
||||
|
'coverUrl': image, |
||||
|
}; |
||||
|
|
||||
|
return Container( |
||||
|
margin: const EdgeInsets.only(bottom: 12), |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), |
||||
|
blurRadius: 3, |
||||
|
spreadRadius: 1, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// 封面图片 |
||||
|
Container( |
||||
|
width: 50, |
||||
|
height: 50, |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
color: Colors.grey[300], |
||||
|
), |
||||
|
child: image.isNotEmpty |
||||
|
? ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
child: Image.network( |
||||
|
image, |
||||
|
fit: BoxFit.cover, |
||||
|
errorBuilder: (context, error, stackTrace) { |
||||
|
return Icon( |
||||
|
Icons.music_note, |
||||
|
color: Colors.grey[600], |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
|
: Icon( |
||||
|
Icons.music_note, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
// 歌曲信息 |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
name, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
singer, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
// 播放按钮 |
||||
|
Obx(() { |
||||
|
final isThisPlaying = |
||||
|
controller.isPlaying.value && controller.currentUrl.value == url; |
||||
|
|
||||
|
// 只有当前播放的歌曲才显示进度 |
||||
|
final currentProgress = isThisPlaying ? controller.progress.value : 0.0; |
||||
|
|
||||
|
return CircularProgressButton( |
||||
|
isPlaying: isThisPlaying, |
||||
|
progress: currentProgress, |
||||
|
primaryColor: Theme.of(context).primaryColor, |
||||
|
backgroundColor: Colors.grey, |
||||
|
size: 40.0, |
||||
|
strokeWidth: 2.5, |
||||
|
onPlayPause: () { |
||||
|
if (controller.isPlaying.value) { |
||||
|
if (controller.currentUrl.value == url) { |
||||
|
controller.musicPause(); |
||||
|
} else { |
||||
|
controller.musicPlay(song); |
||||
|
} |
||||
|
} else { |
||||
|
if (controller.currentUrl.value == url) { |
||||
|
controller.musicResume(); |
||||
|
} else { |
||||
|
controller.musicPlay(song); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
onSeek: isThisPlaying ? (seekProgress) { |
||||
|
// 只有当前播放的歌曲才允许跳转 |
||||
|
final targetPosition = (controller.duration.value * seekProgress).round(); |
||||
|
controller.seekToPosition(targetPosition); |
||||
|
} : null, |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/translation_list_controller.dart'; |
||||
|
|
||||
|
class TranslationListBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
// 注册控制器 |
||||
|
Get.lazyPut<TranslationListController>(() => TranslationListController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,184 @@ |
|||||
|
import 'dart:io'; |
||||
|
import 'package:audio_waveforms/audio_waveforms.dart'; |
||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:just_audio/just_audio.dart'; |
||||
|
import 'package:path_provider/path_provider.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../core/utils/permission_util.dart'; |
||||
|
|
||||
|
import 'package:share_plus/share_plus.dart'; |
||||
|
|
||||
|
class TranslationListController extends GetxController { |
||||
|
// 是否还有更多数据 |
||||
|
bool isGetMore = true; |
||||
|
bool isLoading = false; |
||||
|
int page = 1; |
||||
|
int addQuantity = 0; |
||||
|
List<String> selected = []; |
||||
|
|
||||
|
final appDocDir = getApplicationDocumentsDirectory(); |
||||
|
// final directory = Directory( |
||||
|
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); |
||||
|
final RxList<FileSystemEntity> files = <FileSystemEntity>[].obs; |
||||
|
|
||||
|
RxList dataList = [].obs; |
||||
|
|
||||
|
List dataListTask = []; |
||||
|
bool isDisposed = false; |
||||
|
// 添加音频播放器 |
||||
|
// 添加音频时长缓存 |
||||
|
final Map<String, Duration> audioDurations = {}; |
||||
|
// 播放状态 |
||||
|
final RxBool isPlaying = false.obs; |
||||
|
// 当前播放的文件路径 |
||||
|
final RxString currentPlayingPath = ''.obs; |
||||
|
final AudioPlayer audioPlayer = AudioPlayer(); |
||||
|
final PlayerController playerController = PlayerController(); |
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// listController.addListener(_onScroll); |
||||
|
_requestStoragePermission(); |
||||
|
//Permission.storage.request(); |
||||
|
// _headers = { |
||||
|
// 'Authorization': 'Bearer; $_token', |
||||
|
// 'Content-Type': 'application/json', |
||||
|
// }; |
||||
|
// _getDataList(); |
||||
|
// addMeetingAudio(); |
||||
|
loadTranslatAudio(); |
||||
|
// 监听播放状态变化 |
||||
|
audioPlayer.playerStateStream.listen((playerState) { |
||||
|
isPlaying.value = playerState.playing; |
||||
|
if (playerState.processingState == ProcessingState.completed) { |
||||
|
// 播放完成后重置状态 |
||||
|
currentPlayingPath.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
audioPlayer.dispose(); // 释放播放器资源 |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// 播放/暂停音频 |
||||
|
void togglePlay(FileSystemEntity file) async { |
||||
|
final filePath = file.path; |
||||
|
|
||||
|
if (currentPlayingPath.value == filePath) { |
||||
|
// 同一个文件:切换播放/暂停 |
||||
|
if (isPlaying.value) { |
||||
|
await audioPlayer.pause(); |
||||
|
} else { |
||||
|
await audioPlayer.play(); |
||||
|
} |
||||
|
} else { |
||||
|
// 新文件:停止当前播放,开始新文件 |
||||
|
currentPlayingPath.value = filePath; |
||||
|
try { |
||||
|
await audioPlayer.stop(); |
||||
|
await audioPlayer.setFilePath(filePath); |
||||
|
await audioPlayer.play(); |
||||
|
} catch (e) { |
||||
|
print("播放失败: $e"); |
||||
|
currentPlayingPath.value = ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 请求存储权限 |
||||
|
void _requestStoragePermission() async { |
||||
|
// final BuildContext context = Get.overlayContext!; |
||||
|
|
||||
|
PermissionUtil.instance |
||||
|
.requestPermission( |
||||
|
// context: context, |
||||
|
permissionType: Permission.storage, |
||||
|
permissionName: "存储", |
||||
|
explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。", |
||||
|
permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。", |
||||
|
) |
||||
|
.then((granted) { |
||||
|
if (granted) { |
||||
|
Logger.d("Permission", "用户授予了存储权限"); |
||||
|
} else { |
||||
|
Logger.d("Permission", "用户未授予存储权限"); |
||||
|
// 可以提示用户无法进行文件操作 |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 添加方法:获取音频时长 |
||||
|
Future<Duration> getAudioDuration(String filePath) async { |
||||
|
if (audioDurations.containsKey(filePath)) { |
||||
|
return audioDurations[filePath]!; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final audioPlayer = AudioPlayer(); |
||||
|
await audioPlayer.setFilePath(filePath); |
||||
|
final duration = await audioPlayer.duration ?? Duration.zero; |
||||
|
audioDurations[filePath] = duration; |
||||
|
return duration; |
||||
|
} catch (e) { |
||||
|
print("获取音频时长失败: $e"); |
||||
|
return Duration.zero; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void loadTranslatAudio() async { |
||||
|
try { |
||||
|
final appDir = await appDocDir; // 先 await 获取目录 |
||||
|
final dir = Directory("${appDir.path}/TranslatAudio"); |
||||
|
|
||||
|
print("appDocDir=${dir?.path}"); |
||||
|
// Create directory if it doesn't exist |
||||
|
if (!await dir.exists()) { |
||||
|
await dir.create(recursive: true); // Create directory tree if needed |
||||
|
files.value = []; // No files exist in new directory |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Directory exis |
||||
|
files.value = dir |
||||
|
.listSync() |
||||
|
.where((f) => |
||||
|
f is File && |
||||
|
(f.path.endsWith('.mp3') || |
||||
|
f.path.endsWith('.wav') || |
||||
|
f.path.endsWith('.aac') || |
||||
|
f.path.endsWith('.m4a') || |
||||
|
f.path.endsWith('.mp4') || |
||||
|
f.path.endsWith('.mov'))) |
||||
|
.toList(); |
||||
|
} catch (e) { |
||||
|
print("Error loading files: $e"); |
||||
|
files.value = []; // Return empty list on error |
||||
|
} |
||||
|
} |
||||
|
// 导入音频文件 |
||||
|
|
||||
|
Future<void> shareAllTranslatAudio() async { |
||||
|
final filePath = selected[0]; |
||||
|
// for (final filePath in selected) { |
||||
|
// if (filePath.isEmpty) { |
||||
|
// EasyLoading.showToast('没有可分享的音频文件'); |
||||
|
// return; |
||||
|
// } |
||||
|
// } |
||||
|
try { |
||||
|
await Share.shareXFiles( |
||||
|
[XFile(filePath)], |
||||
|
text: '分享会议音频', |
||||
|
); |
||||
|
|
||||
|
EasyLoading.showToast('分享完成!'); |
||||
|
} catch (e) { |
||||
|
EasyLoading.showToast('分享失败: ${e.toString()}'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,473 @@ |
|||||
|
import 'dart:io'; |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:haptic_feedback/haptic_feedback.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import '../../../core/theme/app_colors.dart'; |
||||
|
import '../../../routes/app_routes.dart'; |
||||
|
import '../controllers/translation_list_controller.dart'; |
||||
|
|
||||
|
class TranslationListView extends GetView<TranslationListController> { |
||||
|
const TranslationListView({super.key}); |
||||
|
static OverlayEntry? _recordingOverlayEntry; |
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
appBar: _buildAppBar(context, isDarkMode), |
||||
|
body: _buildAudioList(context, isDarkMode), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建应用栏 |
||||
|
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) { |
||||
|
return AppBar( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
||||
|
elevation: 0.5, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: Text( |
||||
|
"翻译录音文件", // |
||||
|
style: TextStyle( |
||||
|
fontSize: 17.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildAudioList(BuildContext context, bool isDarkMode) { |
||||
|
return Obx(() { |
||||
|
final fileList = controller.files; // 不要 toList() |
||||
|
if (fileList.isEmpty) { |
||||
|
return Center(child: Text('暂无音频文件')); |
||||
|
} |
||||
|
|
||||
|
return ListView.builder( |
||||
|
itemCount: controller.files.length, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), |
||||
|
itemBuilder: (context, index) { |
||||
|
final file = controller.files[index]; |
||||
|
return _audioItem(context, isDarkMode, file); |
||||
|
}, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _audioItem( |
||||
|
BuildContext context, bool isDarkMode, FileSystemEntity file) { |
||||
|
final fileName = file.path.split('/').last; |
||||
|
final fileStat = file.statSync(); // 获取文件信息 |
||||
|
final modifiedTime = fileStat.modified; |
||||
|
// 添加 FutureBuilder 来异步获取音频时长 |
||||
|
return FutureBuilder<Duration>( |
||||
|
future: controller.getAudioDuration(file.path), |
||||
|
builder: (context, snapshot) { |
||||
|
String durationText = "计算中..."; |
||||
|
if (snapshot.hasData) { |
||||
|
final duration = snapshot.data!; |
||||
|
if (duration.inSeconds > 0) { |
||||
|
durationText = |
||||
|
"${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}"; |
||||
|
} else { |
||||
|
durationText = "未知"; |
||||
|
} |
||||
|
} else if (snapshot.hasError) { |
||||
|
durationText = "错误"; |
||||
|
} |
||||
|
return GestureDetector( |
||||
|
onLongPressStart: (details) { |
||||
|
_onLongPressAudioItem(context, isDarkMode, details, file); |
||||
|
print("长按操作: $fileName"); |
||||
|
}, |
||||
|
onTap: () { |
||||
|
// 添加点击播放功能 |
||||
|
controller.togglePlay(file); |
||||
|
}, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), |
||||
|
margin: EdgeInsets.symmetric(vertical: 5.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.w), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: isDarkMode |
||||
|
? Colors.black.withOpacity(0.2) |
||||
|
: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
/// 文件名 |
||||
|
Text( |
||||
|
fileName, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
fontSize: 18.sp, |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
|
||||
|
/// 修改时间 & 大小 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 5.w), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.access_time, |
||||
|
size: 14.w, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
), |
||||
|
4.horizontalSpace, |
||||
|
Text( |
||||
|
DateFormat('yyyy-MM-dd HH:mm').format(modifiedTime), |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
20.horizontalSpace, |
||||
|
Icon( |
||||
|
Icons.sd_storage, |
||||
|
size: 14.w, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
), |
||||
|
4.horizontalSpace, |
||||
|
Text( |
||||
|
'${(fileStat.size / 1024).toStringAsFixed(1)} KB', |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
20.horizontalSpace, |
||||
|
Icon(Icons.music_note, |
||||
|
size: 14.w, |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[500]), |
||||
|
4.horizontalSpace, |
||||
|
Text( |
||||
|
durationText, // 使用计算出的时长文本 |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
/// 文件类型标签 |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w), |
||||
|
margin: EdgeInsets.only(top: 5.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(4.w), |
||||
|
), |
||||
|
child: Text( |
||||
|
file.path.split('.').last.toUpperCase(), |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
fontSize: 10.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Obx(() { |
||||
|
final isCurrentFile = |
||||
|
controller.currentPlayingPath.value == file.path; |
||||
|
final isPlaying = controller.isPlaying.value; |
||||
|
|
||||
|
return Row( |
||||
|
children: [ |
||||
|
// 播放/暂停图标 |
||||
|
if (isCurrentFile) |
||||
|
Icon( |
||||
|
isPlaying ? Icons.pause : Icons.play_arrow, |
||||
|
size: 20.w, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
|
||||
|
// 播放状态文本 |
||||
|
Text( |
||||
|
isCurrentFile |
||||
|
? (isPlaying ? '播放中...' : '已暂停') |
||||
|
: '点击播放', |
||||
|
style: TextStyle( |
||||
|
color: Colors.blue, |
||||
|
fontSize: 10.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _onLongPressAudioItem(BuildContext context, bool isDarkMode, |
||||
|
LongPressStartDetails details, FileSystemEntity file) { |
||||
|
Haptics.vibrate(HapticsType.success); |
||||
|
|
||||
|
RenderBox? renderBox = |
||||
|
Overlay.of(context).context.findRenderObject() as RenderBox; |
||||
|
|
||||
|
final RelativeRect position = RelativeRect.fromRect( |
||||
|
Rect.fromLTRB( |
||||
|
details.globalPosition.dx, |
||||
|
details.globalPosition.dy, |
||||
|
details.globalPosition.dx + 110, |
||||
|
details.globalPosition.dy - 40, |
||||
|
), |
||||
|
Offset.zero & renderBox.size, |
||||
|
); |
||||
|
|
||||
|
final fileName = file.path.split('/').last; |
||||
|
|
||||
|
showMenu( |
||||
|
context: context, |
||||
|
position: position, |
||||
|
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
items: [ |
||||
|
_popupMenuEntry( |
||||
|
Icon( |
||||
|
Icons.drive_file_rename_outline, |
||||
|
size: 20.w, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
'重命名', |
||||
|
isDarkMode ? Colors.white : Colors.black87, |
||||
|
() { |
||||
|
// 弹出重命名输入框 |
||||
|
_renameAudioBottomSheet(context, isDarkMode, file); |
||||
|
}, |
||||
|
), |
||||
|
_popupMenuEntry( |
||||
|
Icon( |
||||
|
Icons.delete_outline, |
||||
|
size: 20.w, |
||||
|
color: isDarkMode ? Colors.red[700] : Colors.red, |
||||
|
), |
||||
|
'删除', |
||||
|
isDarkMode ? Colors.red[700]! : Colors.red, |
||||
|
() { |
||||
|
file.delete(); |
||||
|
controller.loadTranslatAudio(); // 刷新列表 |
||||
|
}, |
||||
|
), |
||||
|
_popupMenuEntry( |
||||
|
Icon( |
||||
|
Icons.delete_outline, |
||||
|
size: 20.w, |
||||
|
color: isDarkMode ? Colors.red[700] : Colors.red, |
||||
|
), |
||||
|
'导入', |
||||
|
isDarkMode ? Colors.red[700]! : Colors.red, |
||||
|
() { |
||||
|
Get.toNamed(Routes.meeting, arguments: { |
||||
|
'filePath': file.path, |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
PopupMenuEntry _popupMenuEntry( |
||||
|
Icon icon, |
||||
|
String title, |
||||
|
Color color, |
||||
|
Function() onTap, |
||||
|
) { |
||||
|
return PopupMenuItem( |
||||
|
onTap: onTap, |
||||
|
child: SizedBox( |
||||
|
width: 140.w, |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
icon, |
||||
|
8.horizontalSpace, |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
color: color, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _renameAudioBottomSheet( |
||||
|
BuildContext context, |
||||
|
bool isDarkMode, |
||||
|
FileSystemEntity file, |
||||
|
) { |
||||
|
final TextEditingController _renameController = TextEditingController( |
||||
|
text: file.path.split('/').last, |
||||
|
); |
||||
|
|
||||
|
Get.bottomSheet( |
||||
|
Padding( |
||||
|
padding: MediaQuery.of(context).viewInsets, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w), |
||||
|
color: isDarkMode ? AppColors.darkBackground : Colors.grey[50], |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'重命名音频文件', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
height: 48.w, |
||||
|
margin: EdgeInsets.only(top: 10.w, bottom: 20.w), |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode |
||||
|
? Colors.white.withOpacity(0.05) |
||||
|
: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.w), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: isDarkMode |
||||
|
? Colors.black.withOpacity(0.2) |
||||
|
: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: TextField( |
||||
|
controller: _renameController, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
decoration: InputDecoration( |
||||
|
hintText: '请输入新的文件名', |
||||
|
hintStyle: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black54, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
fillColor: Colors.transparent, |
||||
|
border: InputBorder.none, |
||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 10.w), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 30.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
|
children: [ |
||||
|
GestureDetector( |
||||
|
onTap: () { |
||||
|
Get.back(); |
||||
|
}, |
||||
|
child: Container( |
||||
|
width: 120.w, |
||||
|
height: 40.w, |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all( |
||||
|
width: 2.w, |
||||
|
color: isDarkMode ? AppColors.primary : Colors.blue, |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(12.w), |
||||
|
), |
||||
|
child: Text( |
||||
|
'取消', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? AppColors.primary : Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: () async { |
||||
|
final newName = _renameController.text.trim(); |
||||
|
if (newName.isEmpty) return; |
||||
|
|
||||
|
final dir = file.parent.path; |
||||
|
final newPath = '$dir/$newName'; |
||||
|
|
||||
|
try { |
||||
|
await file.rename(newPath); |
||||
|
controller.loadTranslatAudio(); // 刷新 |
||||
|
Get.back(); |
||||
|
} catch (e) { |
||||
|
print('重命名失败: $e'); |
||||
|
} |
||||
|
}, |
||||
|
child: Container( |
||||
|
width: 120.w, |
||||
|
height: 40.w, |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? AppColors.primary : Colors.blue, |
||||
|
borderRadius: BorderRadius.circular(12.w), |
||||
|
), |
||||
|
child: Text( |
||||
|
'确认', |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
isScrollControlled: true, |
||||
|
clipBehavior: Clip.hardEdge, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,254 @@ |
|||||
|
package com.yunqiinnovation.azure_speech.tools |
||||
|
|
||||
|
import java.io.File |
||||
|
import java.io.FileOutputStream |
||||
|
import java.io.RandomAccessFile |
||||
|
|
||||
|
import android.util.Log |
||||
|
import java.io.FileInputStream |
||||
|
import java.util.concurrent.LinkedBlockingQueue |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
|
||||
|
object RecordFile { |
||||
|
private var currentAudioFile: File? = null |
||||
|
private var fos: FileOutputStream? = null |
||||
|
|
||||
|
// 新增:用于异步写入的队列和线程 |
||||
|
private val writeQueue = LinkedBlockingQueue<ByteArray>() |
||||
|
private val isWriting = AtomicBoolean(false) |
||||
|
private var writeThread: Thread? = null |
||||
|
|
||||
|
// 用于存储音频数据的缓冲区 |
||||
|
private val dataBuffer = mutableListOf<ByteArray>() |
||||
|
private var totalBytesWritten = 0 |
||||
|
|
||||
|
// 音频配置 |
||||
|
private val sampleRate = 16000 |
||||
|
|
||||
|
// 新增:用于存储最后成功保存的文件 |
||||
|
private var lastSavedFile: File? = null |
||||
|
private var lastFileTimestamp: String = "" |
||||
|
var isPause = false |
||||
|
var fileName = "" |
||||
|
|
||||
|
|
||||
|
// 修改:添加 filePath 参数 |
||||
|
internal fun creatingFiles(filePath: String) { |
||||
|
if (fos != null || currentAudioFile != null) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val file = File(filePath) |
||||
|
val parentDir = file.parentFile |
||||
|
if (!parentDir.exists()) { |
||||
|
parentDir.mkdirs() |
||||
|
} |
||||
|
|
||||
|
currentAudioFile = file |
||||
|
currentAudioFile?.createNewFile() |
||||
|
|
||||
|
fos = FileOutputStream(currentAudioFile).apply { |
||||
|
write(generateWavHeader(0)) |
||||
|
close() |
||||
|
} |
||||
|
|
||||
|
fos = FileOutputStream(currentAudioFile, true) |
||||
|
startWriteThread() |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// 新增:启动写入线程 |
||||
|
private fun startWriteThread() { |
||||
|
if (isWriting.get()) return |
||||
|
isWriting.set(true) |
||||
|
writeThread = Thread { |
||||
|
try { |
||||
|
while (isWriting.get() || writeQueue.isNotEmpty()) { |
||||
|
val data = writeQueue.poll() ?: continue |
||||
|
fos?.write(data) |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("tag", "异步写入音频数据失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
writeThread?.start() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存音频数据到 WAV 文件(异步) |
||||
|
*/ |
||||
|
internal fun saveAudioDataToWav(buffer: ByteArray) { |
||||
|
if (fos == null || currentAudioFile == null) return |
||||
|
// 放入队列,由写线程写入 |
||||
|
writeQueue.offer(buffer.copyOf()) |
||||
|
totalBytesWritten += buffer.size |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// 更新文件头生成(修正RIFF长度计算) |
||||
|
private fun generateWavHeader(dataLength: Int): ByteArray { |
||||
|
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 |
||||
|
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数 |
||||
|
|
||||
|
return byteArrayOf( |
||||
|
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), |
||||
|
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), |
||||
|
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), |
||||
|
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), |
||||
|
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), |
||||
|
16, 0, 0, 0, // PCM头长度 |
||||
|
1, 0, // PCM格式 |
||||
|
1, 0, // 单声道 |
||||
|
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率 |
||||
|
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率 |
||||
|
2, 0, // 块对齐 (通道数 * 样本位数/8) |
||||
|
16, 0, // 样本位数 |
||||
|
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), |
||||
|
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), |
||||
|
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重命名指定路径的音频文件 |
||||
|
* @param filePath 要重命名的文件完整路径 |
||||
|
* @param newName 新的文件名(不含扩展名) |
||||
|
* @return true 重命名成功,false 失败 |
||||
|
*/ |
||||
|
internal fun renameFile(filePath: String, newName: String): Boolean { |
||||
|
val sourceFile = File(filePath) |
||||
|
|
||||
|
// 验证源文件存在 |
||||
|
if (!sourceFile.exists()) { |
||||
|
Log.w("tag", "重命名失败: 文件不存在 $filePath") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 防止重命名正在录制的文件 |
||||
|
if (sourceFile == currentAudioFile) { |
||||
|
Log.w("tag", "重命名失败: 不能重命名正在录制的文件") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
val parentDir = sourceFile.parentFile |
||||
|
val newFile = File(parentDir, "$newName.wav") // 保持.wav扩展名 |
||||
|
|
||||
|
// 处理目标文件已存在的情况 |
||||
|
if (newFile.exists()) { |
||||
|
Log.w("tag", "重命名失败: 目标文件已存在 ${newFile.absolutePath}") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 执行重命名 |
||||
|
return try { |
||||
|
val success = sourceFile.renameTo(newFile) |
||||
|
if (success) { |
||||
|
Log.d("tag", "文件重命名成功: ${sourceFile.name} -> ${newFile.name}") |
||||
|
// 更新最后保存的文件引用(如果是同一个文件) |
||||
|
if (lastSavedFile == sourceFile) { |
||||
|
lastSavedFile = newFile |
||||
|
} |
||||
|
} else { |
||||
|
Log.w("tag", "文件重命名操作失败") |
||||
|
} |
||||
|
success |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("tag", "重命名异常: ${e.message}") |
||||
|
false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 移动文件到新路径 |
||||
|
* @param sourcePath 原始文件完整路径 |
||||
|
* @param destPath 目标文件完整路径 |
||||
|
* @return true 移动成功,false 移动失败 |
||||
|
*/ |
||||
|
internal fun moveFile(sourcePath: String, destPath: String): Boolean { |
||||
|
val sourceFile = File(sourcePath) |
||||
|
val destFile = File(destPath) |
||||
|
|
||||
|
if (!sourceFile.exists()) { |
||||
|
Log.w("tag", "移动失败: 源文件不存在 ${sourceFile.absolutePath}") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 确保目标目录存在 |
||||
|
val destDir = destFile.parentFile |
||||
|
if (!destDir.exists() && !destDir.mkdirs()) { |
||||
|
Log.w("tag", "移动失败: 无法创建目标目录 ${destDir.absolutePath}") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 删除已存在的目标文件 |
||||
|
if (destFile.exists() && !destFile.delete()) { |
||||
|
Log.w("tag", "移动失败: 无法覆盖已存在文件 ${destFile.absolutePath}") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return try { |
||||
|
// 使用renameTo尝试移动(同分区最快) |
||||
|
if (sourceFile.renameTo(destFile)) { |
||||
|
true |
||||
|
} else { |
||||
|
// 跨分区移动:复制+删除 |
||||
|
FileInputStream(sourceFile).use { input -> |
||||
|
FileOutputStream(destFile).use { output -> |
||||
|
input.copyTo(output) |
||||
|
} |
||||
|
} |
||||
|
if (!sourceFile.delete()) { |
||||
|
Log.w("tag", "移动警告: 源文件删除失败 ${sourceFile.absolutePath}") |
||||
|
} |
||||
|
true |
||||
|
}.also { |
||||
|
if (it) { |
||||
|
Log.d("tag", "文件移动成功: ${sourcePath} -> ${destPath}") |
||||
|
// 更新最后保存的文件引用 |
||||
|
if (lastSavedFile?.absolutePath == sourcePath) { |
||||
|
lastSavedFile = destFile |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("tag", "文件移动异常: ${e.message}") |
||||
|
false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal fun closeFile(isSave: Boolean): Boolean { |
||||
|
try { |
||||
|
if (fos == null || currentAudioFile == null) { |
||||
|
return false |
||||
|
} |
||||
|
isWriting.set(false) |
||||
|
writeThread?.join(500) |
||||
|
fos?.close() |
||||
|
|
||||
|
currentAudioFile?.let { file -> |
||||
|
if (file.length() <= 44 || isSave == false) { |
||||
|
file.delete() |
||||
|
Log.d("tag", "音频文件过小已删除: ${file.absolutePath}") |
||||
|
return false |
||||
|
} else { |
||||
|
RandomAccessFile(file, "rw").use { raf -> |
||||
|
raf.seek(0) |
||||
|
raf.write(generateWavHeader(totalBytesWritten)) |
||||
|
} |
||||
|
Log.d("tag", "音频文件保存完成: ${file.absolutePath}") |
||||
|
|
||||
|
// 保存成功文件引用 |
||||
|
lastSavedFile = file |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("tag", "更新WAV文件头失败: ${e.message}") |
||||
|
return false |
||||
|
} finally { |
||||
|
fos = null |
||||
|
currentAudioFile = null |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue