Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

newdev_shunjiawei
tanlongsheng 1 year ago
parent
commit
6a546c4a7d
  1. 2
      lib/core/bindings/initial_binding.dart
  2. 7
      lib/data/services/asr_service.dart
  3. 14
      lib/data/services/ble_manager.dart
  4. 8
      lib/data/services/location_manager.dart
  5. 12
      lib/data/services/speech_impl/azure_asr_service.dart
  6. 6
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  7. 6
      lib/data/services/speech_impl/volcano_asr_service.dart
  8. 6
      lib/data/services/speech_impl/xunfei_asr_service.dart
  9. 3
      lib/modules/agent/controllers/agent_controller.dart
  10. 6
      lib/modules/agent/views/agent_view.dart
  11. 7
      lib/modules/home/controllers/home_controller.dart
  12. 11
      lib/modules/meeting/bindings/meeting_record_binding.dart
  13. 37
      lib/modules/meeting/controllers/meeting_controller.dart
  14. 23
      lib/modules/meeting/controllers/meeting_details_controller.dart
  15. 254
      lib/modules/meeting/controllers/meeting_record_controller.dart
  16. 104
      lib/modules/meeting/views/meeting_details_view.dart
  17. 239
      lib/modules/meeting/views/meeting_record_view.dart
  18. 400
      lib/modules/meeting/views/meeting_view.dart
  19. 9
      lib/modules/navigation/bindings/navigation_binding.dart
  20. 5
      lib/modules/navigation/controllers/navigation_controller.dart
  21. 17
      lib/modules/navigation/views/navigation_view.dart
  22. 7
      lib/modules/pairing/controllers/pairing_controller.dart
  23. 8
      lib/modules/pairing/views/pairing_view.dart
  24. 17
      lib/routes/app_pages.dart
  25. 2
      lib/routes/app_routes.dart
  26. 22
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  27. 5
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  28. 126
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  29. 16
      local_plugins/location_service/ios/location_service.podspec
  30. 21
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  31. 2
      pubspec.yaml

2
lib/core/bindings/initial_binding.dart

@ -1,3 +1,4 @@
import 'package:deep_voice/data/services/location_manager.dart';
import 'package:deep_voice/data/services/music_manager.dart';
import 'package:deep_voice/data/services/navigation_manager.dart';
import 'package:get/get.dart';
@ -37,6 +38,7 @@ class InitialBinding extends Bindings {
// MusicManager
Get.lazyPut<MusicManager>(() => MusicManager(), fenix: true);
Get.lazyPut<NavigationManager>(() => NavigationManager(), fenix: true);
Get.lazyPut<LocationManager>(() => LocationManager(), fenix: true);
// 火山AI服务
// Get.lazyPut<VolcanoAIService>(() => VolcanoAIService(), fenix: true);

7
lib/data/services/asr_service.dart

@ -29,10 +29,13 @@ abstract class AsrService {
/// 检查连续识别是否活跃
bool isContinuousRecognitionActive();
/// 停止连续语音识别
/// 开始录音
Future<bool> enableRecord(String fileName);
/// 停止连续语音识别
/// 暂停录音
Future<bool> pauseRecord();
/// 结束录音
Future<bool> stopRecord();
/// 释放资源

14
lib/data/services/ble_manager.dart

@ -290,9 +290,17 @@ class BleManager extends GetxService {
}
// 提取电池信息
final int leftBattery = data['leftEarBattery'] as int? ?? 0;
final int rightBattery = data['rightEarBattery'] as int? ?? 0;
final int caseBattery = data['caseBattery'] as int? ?? 0;
final int leftBattery = (data['leftEarBattery'] as int? ?? 0) > 100
? 0
: (data['leftEarBattery'] as int? ?? 0);
final int rightBattery = (data['rightEarBattery'] as int? ?? 0) > 100
? 0
: (data['rightEarBattery'] as int? ?? 0);
final int caseBattery = (data['caseBattery'] as int? ?? 0) > 100
? 0
: (data['caseBattery'] as int? ?? 0);
final bool leftCharging = data['leftEarCharging'] as bool? ?? false;
final bool rightCharging = data['rightEarCharging'] as bool? ?? false;
final bool caseCharging = data['caseCharging'] as bool? ?? false;

8
lib/data/services/location_manager.dart

@ -1,10 +1,8 @@
import 'package:deep_voice/core/utils/logger.dart';
import 'package:get/get.dart';
import 'package:location_service/location_service.dart';
import 'package:music_service/music_service.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/utils/permission_util.dart';
import 'package:flutter/material.dart';
//音乐播放管理
class LocationManager extends GetxService {
@ -24,10 +22,10 @@ class LocationManager extends GetxService {
Future<void> requestNotificationPermission() async {
final granted = await PermissionUtil.instance.requestPermission(
// context: context,
permissionType: Permission.notification,
permissionType: Permission.locationAlways,
permissionName: '通知',
explanationText: '需要通知权限来显示音乐播放状态。',
permanentDenialText: '您已永久拒绝通知权限,请前往设置开启权限。',
explanationText: '需要定位权限来获取当前位置。',
permanentDenialText: '您已永久定位通知权限,请前往设置开启权限。',
settingsButtonText: '去设置',
cancelButtonText: '取消',
);

12
lib/data/services/speech_impl/azure_asr_service.dart

@ -368,4 +368,16 @@ class AzureAsrService extends GetxService implements AsrService {
rethrow;
}
}
@override
Future<bool> pauseRecord() async {
try {
final bool result = await _channel.invokeMethod('pauseRecord');
return result;
} catch (e) {
Logger.error('停止录音: ${e.toString()}');
rethrow;
}
}
}

6
lib/data/services/speech_impl/volcano_asr_api_service.dart

@ -823,4 +823,10 @@ class VolcanoAsrApiService implements AsrService {
// TODO: implement stopRecord
throw UnimplementedError();
}
@override
Future<bool> pauseRecord() {
// TODO: implement pauseRecord
throw UnimplementedError();
}
}

6
lib/data/services/speech_impl/volcano_asr_service.dart

@ -358,4 +358,10 @@ class VolcanoAsrService extends GetxService implements AsrService {
// TODO: implement stopRecord
throw UnimplementedError();
}
@override
Future<bool> pauseRecord() {
// TODO: implement pauseRecord
throw UnimplementedError();
}
}

6
lib/data/services/speech_impl/xunfei_asr_service.dart

@ -282,4 +282,10 @@ class XunfeiAsrService extends GetxService implements AsrService {
// TODO: implement stopRecord
throw UnimplementedError();
}
@override
Future<bool> pauseRecord() {
// TODO: implement pauseRecord
throw UnimplementedError();
}
}

3
lib/modules/agent/controllers/agent_controller.dart

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:amap_service/amap_service.dart';
import 'package:deep_voice/data/services/location_manager.dart';
import 'package:deep_voice/data/services/music_manager.dart';
import 'package:deep_voice/data/services/navigation_manager.dart';
import 'package:flutter/material.dart';
@ -502,6 +503,8 @@ class AgentController extends GetxController {
//场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。
//场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。
Future<void> sendMessage() async {
// 收回键盘
FocusManager.instance.primaryFocus?.unfocus();
// 1. 处理图片+文本的混合输入
if (isImageInputActive.value && selectedImagePath.value != null) {
final text = textController.text.trim();

6
lib/modules/agent/views/agent_view.dart

@ -364,8 +364,12 @@ class AgentView extends StatelessWidget {
return TextField(
controller: controller.textController,
maxLines: isImageActive ? 2 : 1,
maxLines: isImageActive ? 2 : null,
minLines: isImageActive ? 2 : 1,
keyboardType:
TextInputType.multiline, // 启用多行键盘
textInputAction:
TextInputAction.newline, // 支持回车换行
style: TextStyle(
fontSize: 15,
color: isDarkMode

7
lib/modules/home/controllers/home_controller.dart

@ -275,7 +275,8 @@ class HomeController extends GetxController {
String getEarbudsText() {
if (deviceInfo == null ||
(deviceInfo.value!.leftEarBattery <= 0 &&
deviceInfo.value!.rightEarBattery <= 0)) {
deviceInfo.value!.rightEarBattery <= 0 ||
deviceInfo.value!.caseBattery > 100)) {
return 'unknown'.tr;
}
@ -296,7 +297,9 @@ class HomeController extends GetxController {
/// 获取充电仓电量文本
String getCaseText() {
if (deviceInfo.value == null || deviceInfo.value!.caseBattery <= 0) {
if (deviceInfo.value == null ||
deviceInfo.value!.caseBattery <= 0 ||
deviceInfo.value!.caseBattery > 100) {
return 'unknown'.tr;
}

11
lib/modules/meeting/bindings/meeting_record_binding.dart

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/meeting_record_controller.dart';
/// 录音模块依赖绑定类
class MeetingRecordBinding extends Bindings {
@override
void dependencies() {
// 延迟注册 MeetingRecordController 控制器
Get.lazyPut<MeetingRecordController>(() => MeetingRecordController());
}
}

37
lib/modules/meeting/controllers/meeting_controller.dart

@ -15,6 +15,8 @@ import '../../../core/utils/upload_oss.dart';
import '../../../data/services/db/sqflite_api.dart';
import '../../../core/utils/permission_util.dart';
import 'package:share_plus/share_plus.dart';
class MeetingController extends GetxController {
final ScrollController listController = ScrollController();
final AudioPlayer _audioPlayer = AudioPlayer();
@ -112,10 +114,24 @@ class MeetingController extends GetxController {
});
}
void loadTranslatopus() async {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/OpusAudio");
print("appDocDir=${dir?.path}");
files = dir
.listSync()
.where((f) => f is File && (f.path.endsWith('.opus')))
.toList();
}
void loadTranslatAudio() async {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
files = dir
.listSync()
.where((f) =>
@ -128,6 +144,27 @@ class MeetingController extends GetxController {
f.path.endsWith('.mov')))
.toList();
}
// 导入音频文件
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()}');
}
}
void addTranslatAudio() async {
EasyLoading.show(status: '正在批量导入...');

23
lib/modules/meeting/controllers/meeting_details_controller.dart

@ -17,6 +17,7 @@ import '../../../data/services/deapsound_ai_service.dart';
import '../model/meeting_details_model.dart';
import '../model/meeting_model.dart';
import 'meeting_controller.dart';
import 'package:share_plus/share_plus.dart';
class MeetingDetailsController extends GetxController
with GetTickerProviderStateMixin {
@ -422,4 +423,26 @@ class MeetingDetailsController extends GetxController
}
allText = text;
}
// 导入音频文件
Future<void> shareAllTranslatAudio() async {
// 确保 filepath 是字符串列表类型
if (meetingData.value.filepath.isEmpty) {
EasyLoading.showToast('没有可分享的音频文件');
return;
}
try {
// 直接使用文件路径列表创建 XFile 列表
await Share.shareXFiles(
[XFile(meetingData.value.filepath)], // 注意:这里用方括号创建包含单个文件的列表
text: '分享会议音频',
);
EasyLoading.showToast('分享完成!');
} catch (e) {
EasyLoading.showToast('分享失败: ${e.toString()}');
}
}
}

254
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -0,0 +1,254 @@
// meeting_record_controller.dart
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
// 新增计时器状态枚举
enum TimerStatus {
stopped, // 已停止/未开始
running, // 运行中
paused, // 已暂停
}
/// 控制器:处理录音状态、波形、文本转录等逻辑
class MeetingRecordController extends GetxController
with SingleGetTickerProviderMixin {
//语言服务
final AsrService _asrService = Get.find<AsrService>();
//语言服务监听
StreamSubscription? _recognitionSubscription;
//是否开启识别
final RxBool isVoiceRecognitionActive = false.obs;
// 音频输入源
bool _audioSourceType = false;
//识别文本
RxString intermediateContent = ''.obs;
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; // 当前播放位置(用于波形图动画)
// 新增计时器状态变量
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs;
int _elapsedMilliseconds = 0; // 累计毫秒数
int? _lastStartTime; // 上次开始计时的时间戳
@override
void onInit() {
super.onInit();
_asrService.initialize();
tabController = TabController(length: 2, vsync: this);
generateWaveData();
// 移除初始化的 startTimer() 调用
}
@override
void onClose() {
_timer?.cancel();
_asrService.dispose();
tabController.dispose();
super.onClose();
}
/// 启动/继续计时器
void startTimer() {
if (timerStatus.value != TimerStatus.running) {
timerStatus.value = TimerStatus.running;
_lastStartTime = DateTime.now().millisecondsSinceEpoch;
_timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
if (timerStatus.value == TimerStatus.running) {
// 计算真实经过的时间(毫秒)
final currentTime = DateTime.now().millisecondsSinceEpoch;
_elapsedMilliseconds += currentTime - _lastStartTime!;
_lastStartTime = currentTime;
// 转换为秒(四舍五入)
seconds.value = (_elapsedMilliseconds / 1000).round();
update();
}
});
}
}
/// 暂停计时器
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;
}
}
/// 重置计时器
void resetTimer() {
_timer?.cancel();
_timer = null;
_elapsedMilliseconds = 0;
_lastStartTime = null;
seconds.value = 0;
timerStatus.value = TimerStatus.stopped;
}
void toggleTextDisplay() {
showTextContent.toggle(); // 点击按钮切换显示状态
}
/// 切换录音状态
Future<void> toggleRecording() async {
if (timerStatus.value == TimerStatus.stopped) {
await _startRecording();
} else if (timerStatus.value == TimerStatus.running) {
await _pauseRecording();
} else if (timerStatus.value == TimerStatus.paused) {
await _resumeRecording();
}
}
/// 生成模拟波形图数据
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;
});
}
// 处理语音识别事件
void _handleRecognitionEvent(RecognitionEvent event) {
switch (event.type) {
case RecognitionEventType.intermediateResult:
print(
"_handleRecognitionEvent,intermediateResult,event.text=${event.text}");
intermediateContent.value = event.text;
break;
case RecognitionEventType.finalResult:
print("_handleRecognitionEvent,finalResult,event.text=${event.text}");
finalContent.value += _spliceText(event);
intermediateContent.value = '';
//_originaBytes.add(event.audio!);
break;
default:
// 不做任何处理
break;
}
}
String _spliceText(RecognitionEvent event) {
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 {
// 开始录音
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
await _asrService.enableRecord("录音");
startTimer();
// 蓝牙处理
if (audioTypes == 0) {
_bleManager.openEncoder();
} else if (audioTypes == 1) {
_bleManager.openDecoder();
} else if (audioTypes == 2) {
_bleManager.openA2DPDecoder();
}
}
Future<void> _pauseRecording() async {
// 暂停录音
await _asrService.stopContinuousRecognition();
await _asrService.pauseRecord();
pauseTimer();
//await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法
}
Future<void> _resumeRecording() async {
// 开始录音
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
startTimer();
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法
}
/// 切换是否标记为重点
void toggleMarked() {
isMarked.toggle();
}
/// 格式化时间为 mm:ss
String formatTime(int seconds) {
// 实现你的时间格式化逻辑
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
}
/// 游标在波形图中的位置(0~1)
double get cursorPosition => _currentPosition / waveData.length;
/// 保存录音(目前模拟为返回上一页)
Future<void> saveRecording() async {
resetTimer(); // 重置计时器
_bleManager.closeCodec();
await _asrService.stopRecord();
await _asrService.stopContinuousRecognition();
Get.back();
}
}

104
lib/modules/meeting/views/meeting_details_view.dart

@ -24,7 +24,7 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
child: Obx(
() => Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
appBar: _buildAppBar(context, isDarkMode),
body: _buildBody(isDarkMode),
),
),
@ -32,7 +32,7 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) {
return AppBar(
backgroundColor:
controller.isTop.value ? Colors.grey[50] : Colors.grey[200],
@ -142,7 +142,9 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
color: isDarkMode ? Colors.white : Colors.black,
size: 22.sp,
),
onPressed: () {},
onPressed: () {
_showShareSheet(context, isDarkMode);
},
),
if (!controller.isSpeakerText.value)
IconButton(
@ -274,6 +276,102 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
);
}
// 2. 新增底部分享弹窗方法
void _showShareSheet(BuildContext context, bool isDarkMode) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)),
),
padding: EdgeInsets.only(
left: 0,
right: 0,
top: 12.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 12.w,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 文件名和关闭按钮
Row(
children: [
SizedBox(width: 16.w),
Icon(Icons.insert_drive_file, color: Colors.grey, size: 22.w),
SizedBox(width: 8.w),
Expanded(
child: Text(
controller.meetingData.value.title ?? '会议文件',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.sp,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
),
IconButton(
icon: Icon(Icons.close,
color: isDarkMode ? Colors.white : Colors.black54,
size: 24.w),
onPressed: () => Navigator.of(context).pop(),
),
],
),
SizedBox(height: 8.w),
Divider(
height: 1,
color: isDarkMode ? Colors.white12 : Colors.grey[200]),
// 分享选项
_shareItem(Icons.link, '分享链接', onTap: () {/* TODO */}),
_shareItem(Icons.copy, '复制转写', onTap: () {/* TODO */}),
_shareItem(Icons.copy, '复制总结', onTap: () {/* TODO */}),
// ...existing code...
_shareItem(Icons.multitrack_audio, '导出音频', onTap: () {
controller.shareAllTranslatAudio();
}),
// ...existing code...
_shareItem(Icons.description, '导出转写', onTap: () {/* TODO */}),
_shareItem(Icons.description, '导出总结', onTap: () {/* TODO */}),
_shareItem(Icons.account_tree, '导出思维导图', onTap: () {/* TODO */}),
SizedBox(height: 8.w),
],
),
);
},
);
}
// 3. 分享选项组件
Widget _shareItem(IconData icon, String title, {VoidCallback? onTap}) {
return InkWell(
onTap: () {
Navigator.of(Get.context!).pop();
if (onTap != null) onTap();
},
child: Container(
height: 52.w,
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
children: [
Icon(icon, color: Colors.grey[700], size: 22.w),
SizedBox(width: 16.w),
Text(
title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
),
),
],
),
),
);
}
Widget _meetingWait(String title) {
return Stack(
alignment: Alignment.topCenter,

239
lib/modules/meeting/views/meeting_record_view.dart

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../controllers/meeting_record_controller.dart';
class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('新录音 29', style: TextStyle(fontSize: 18)), // 更新为29
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
body: Column(
children: [
TabBar(
controller: controller.tabController,
indicatorColor: Colors.red,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: '音频'),
Tab(text: '文本'),
],
),
Expanded(
child: TabBarView(
controller: controller.tabController,
children: [
_buildAudioTab(context),
_buildTextTab(context),
],
),
),
],
),
);
}
Widget _buildAudioTab(BuildContext context) {
return Obx(() => Column(
children: [
const SizedBox(height: 24),
SizedBox(
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
painter: WaveformPainter(
controller.waveData,
cursorPosition: controller.cursorPosition,
),
size: Size.infinite,
),
Positioned(
left: MediaQuery.of(context).size.width *
controller.cursorPosition,
child: Container(
width: 2,
height: 120,
color: Colors.red,
),
),
],
),
),
const SizedBox(height: 16),
Text(
controller.formatTime(controller.seconds.value),
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
const Spacer(),
_buildBottomBar(context),
const SizedBox(height: 32),
],
));
}
Widget _buildTextTab(BuildContext context) {
return Obx(() => Column(
children: [
const SizedBox(height: 48),
Center(
child: Column(
children: [
const Icon(Icons.graphic_eq, color: Colors.red, size: 32),
const SizedBox(height: 8),
Text(
controller.formatTime(controller.seconds.value),
style: const TextStyle(
fontSize: 36, fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 24),
// 新增:转文本提示区域
if (!controller.showTextContent.value) ...[
const Text('无文本',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: controller.toggleTextDisplay,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: const Text('转文本'),
),
],
// 文本内容区域(转文本后显示)
if (controller.showTextContent.value)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SingleChildScrollView(
child: Text(
'${controller.finalContent.value}${controller.intermediateContent.value}',
style: const TextStyle(fontSize: 16),
),
),
),
),
const Spacer(),
_buildBottomBar(context),
const SizedBox(height: 32),
],
));
}
Widget _buildBottomBar(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: controller.toggleMarked,
child: Obx(() => CircleAvatar(
radius: 32,
backgroundColor: Colors.white,
child: Icon(
controller.isMarked.value ? Icons.star : Icons.star_border,
size: 36,
color:
controller.isMarked.value ? Colors.orange : Colors.grey,
),
)),
),
GestureDetector(
onTap: controller.toggleRecording,
child: Obx(() => CircleAvatar(
radius: 40,
backgroundColor: Colors.white,
child: Icon(
controller.timerStatus.value == TimerStatus.running
? Icons.pause
: Icons.play_arrow,
size: 44,
color: Colors.red,
),
)),
),
GestureDetector(
onTap: controller.saveRecording,
child: const CircleAvatar(
radius: 32,
backgroundColor: Colors.white,
child: Icon(
Icons.check,
size: 36,
color: Colors.green,
),
),
),
],
);
}
}
class WaveformPainter extends CustomPainter {
final List<double> data;
final double cursorPosition;
WaveformPainter(this.data, {required this.cursorPosition});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue[300]!
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final cursorPaint = Paint()
..color = Colors.red
..strokeWidth = 2;
final double barWidth = size.width / data.length;
final centerY = size.height / 2;
for (int i = 0; i < data.length; i++) {
final x = i * barWidth + barWidth / 2;
final height = data[i];
canvas.drawLine(
Offset(x, centerY - height),
Offset(x, centerY + height),
paint,
);
}
final cursorX = size.width * cursorPosition;
canvas.drawLine(
Offset(cursorX, 0),
Offset(cursorX, size.height),
cursorPaint,
);
}
@override
bool shouldRepaint(covariant WaveformPainter oldDelegate) {
return oldDelegate.data != data ||
oldDelegate.cursorPosition != cursorPosition;
}
}

400
lib/modules/meeting/views/meeting_view.dart

@ -9,31 +9,36 @@ import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart';
import '../controllers/meeting_controller.dart';
import '../model/meeting_model.dart';
import 'dart:io';
import 'meeting_record_view.dart';
class MeetingView extends GetView<MeetingController> {
const MeetingView({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(isDarkMode),
appBar: _buildAppBar(context, isDarkMode),
body: _buildBody(context, isDarkMode),
floatingActionButton: FloatingActionButton(
//onPressed: controller.addAudio,
onPressed: () {
// 显示底部选择菜单
_showAddAudioMenu(context, isDarkMode);
},
child: const Icon(Icons.add),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: "addAudio",
onPressed: () {
_showAddAudioMenu(context, isDarkMode);
},
child: const Icon(Icons.add),
),
],
),
);
}
// ...existing code...
// 底部+号菜单
void _showAddAudioMenu(BuildContext context, bool isDarkMode) {
showModalBottomSheet(
context: context,
@ -56,7 +61,7 @@ class MeetingView extends GetView<MeetingController> {
onTap: () {
Navigator.of(context).pop();
// TODO: 实现开始录音逻辑
// controller.startRecording();
_showStartsRecordSheet(context, isDarkMode);
},
child: Container(
height: 56.w,
@ -178,7 +183,114 @@ class MeetingView extends GetView<MeetingController> {
},
);
}
// ...existing code...
// 新增:从opus导出选择弹窗
void _showLocalOpusPicker(BuildContext context, bool isDarkMode) async {
// 这里以用户目录下的 Downloads 为例,可根据实际需求修改路径
controller.loadTranslatopus();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)),
),
padding: EdgeInsets.only(
left: 0,
right: 0,
top: 12.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 12.w,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'选择音视频文件',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
Divider(
height: 1,
color: isDarkMode ? Colors.white12 : Colors.grey[200]),
SizedBox(height: 8.w),
SizedBox(
height: 300.w,
child: ListView.builder(
itemCount: controller.files.length,
itemBuilder: (context, idx) {
final file = controller.files[idx];
final fileName = file.path.split('/').last;
final isChecked =
controller.selected.contains(file.path);
return CheckboxListTile(
value: isChecked,
onChanged: (checked) {
setState(() {
if (checked == true) {
controller.selected.add(file.path);
} else {
controller.selected.remove(file.path);
}
});
},
title: Text(
fileName,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 15.sp,
),
),
controlAffinity: ListTileControlAffinity.leading,
activeColor: Colors.blue,
);
},
),
),
SizedBox(height: 12.w),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
),
onPressed: () => Navigator.of(context).pop(),
child:
Text('取消', style: TextStyle(color: Colors.white)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
),
onPressed: controller.selected.isEmpty
? null
: () {
Navigator.of(context).pop();
// TODO: 调用导入逻辑
controller.shareAllTranslatAudio();
},
child:
Text('导入选中', style: TextStyle(color: Colors.white)),
),
],
),
SizedBox(height: 8.w),
],
),
);
},
);
},
);
}
// 新增:本地音视频文件选择弹窗
void _showLocalAudioVideoPicker(BuildContext context, bool isDarkMode) async {
@ -288,7 +400,7 @@ class MeetingView extends GetView<MeetingController> {
);
}
// ...existing code...
// 导入音频弹窗
void _showImportAudioSheet(BuildContext context, bool isDarkMode) {
showModalBottomSheet(
context: context,
@ -371,9 +483,128 @@ class MeetingView extends GetView<MeetingController> {
width: 20.w, height: 20.w),
],
),
title: '从录音里导入',
title: '从opus导出',
onTap: () {
Navigator.of(context).pop();
// TODO: 实现从其他APP导入逻辑
_showLocalOpusPicker(context, isDarkMode);
},
),
],
),
),
SizedBox(height: 8.w),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Text(
'支持导入单文件时长不超过5小时的音频',
style: TextStyle(
fontSize: 13.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[600],
),
),
),
SizedBox(height: 16.w),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Icon(Icons.close, size: 32.w, color: Colors.grey),
),
SizedBox(height: 8.w),
],
),
);
},
);
}
// 开始录音弹窗
void _showStartsRecordSheet(BuildContext context, bool isDarkMode) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)),
),
padding: EdgeInsets.only(
left: 0,
right: 0,
top: 12.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 12.w,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.w),
child: Text(
'开始录音',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
),
Divider(
height: 1,
color: isDarkMode ? Colors.white12 : Colors.grey[200]),
// 选项列表
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w),
child: Column(
children: [
_importAudioItem(
context,
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '音/视频录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
// TODO: 实现从文件导入逻辑
},
),
SizedBox(height: 8.w),
_importAudioItem(
context,
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '现场录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
// TODO: 实现从相册导入逻辑
},
),
SizedBox(height: 8.w),
_importAudioItem(
context,
isDarkMode,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/images/explore.png',
width: 20.w, height: 20.w),
SizedBox(width: 4.w),
Image.asset('assets/images/explore.png',
width: 20.w, height: 20.w),
SizedBox(width: 4.w),
Image.asset('assets/images/explore.png',
width: 20.w, height: 20.w),
],
),
title: '通话录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
// TODO: 实现从其他APP导入逻辑
},
),
@ -404,6 +635,7 @@ class MeetingView extends GetView<MeetingController> {
);
}
// 选项框
Widget _importAudioItem(
BuildContext context,
bool isDarkMode, {
@ -444,10 +676,120 @@ class MeetingView extends GetView<MeetingController> {
),
);
}
// ...existing code...
// 新增右侧弹窗
void _showRightDrawerDialog(BuildContext context, bool isDarkMode) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: "rightDrawer",
transitionDuration: const Duration(milliseconds: 300),
pageBuilder: (context, anim1, anim2) {
return Align(
alignment: Alignment.centerRight,
child: FractionallySizedBox(
widthFactor: 0.7,
child: Material(
color: Colors.transparent,
child: Container(
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
color: isDarkMode ? Colors.white : Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.w),
bottomLeft: Radius.circular(18.w),
),
),
child: _buildDrawerContent(context, isDarkMode),
),
),
),
);
},
transitionBuilder: (context, anim1, anim2, child) {
final offsetAnimation = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim1, curve: Curves.easeOutCubic));
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
);
}
// 侧边栏内容
Widget _buildDrawerContent(BuildContext context, bool isDarkMode) {
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(20.w),
child: TextField(
decoration: InputDecoration(
hintText: '在所有文件中搜索',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.w),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[100],
contentPadding: EdgeInsets.zero,
),
),
),
ListTile(
leading: Icon(Icons.folder, color: Colors.red),
title:
Text('全部文件(4)', style: TextStyle(fontWeight: FontWeight.bold)),
onTap: () {},
),
ListTile(
leading: Icon(Icons.category, color: Colors.blue),
title: Text('未分类(4)'),
onTap: () {},
),
ListTile(
leading: Icon(Icons.delete, color: Colors.teal),
title: Text('回收站'),
onTap: () {},
),
Divider(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w),
child: Text('文件夹', style: TextStyle(color: Colors.grey)),
),
...['NOTE', 'CALL', 'NotePin', 'Import', 'Desktop']
.map((name) => ListTile(
title: Text(name),
onTap: () {},
)),
Divider(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w),
child: Text('排序方式', style: TextStyle(color: Colors.grey)),
),
ListTile(
title: Text('创建时间'),
trailing: Icon(Icons.arrow_upward),
onTap: () {},
),
ListTile(
title: Text('修改时间'),
trailing: Icon(Icons.arrow_upward),
onTap: () {},
),
],
),
);
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) {
return AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
elevation: 0.5,
@ -468,6 +810,15 @@ class MeetingView extends GetView<MeetingController> {
),
),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.folder_open,
color: isDarkMode ? Colors.white : Colors.black),
onPressed: () {
_showRightDrawerDialog(context, isDarkMode);
},
),
],
);
}
@ -809,4 +1160,19 @@ class MeetingView extends GetView<MeetingController> {
clipBehavior: Clip.hardEdge,
);
}
// ...existing code...
// void _showRecordingSheet(BuildContext context, bool isDarkMode) {
// if (_recordingOverlayEntry != null) return; // 防止重复添加
// _recordingOverlayEntry = OverlayEntry(
// builder: (ctx) => RecordingFloatingPanel(
// onStop: () {
// _recordingOverlayEntry?.remove();
// _recordingOverlayEntry = null;
// // 录音完成后的逻辑
// },
// ),
// );
// Overlay.of(context).insert(_recordingOverlayEntry!);
// }
}

9
lib/modules/navigation/bindings/navigation_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/navigation_controller.dart';
class NavigationBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<NavigationController>(() => NavigationController());
}
}

5
lib/modules/navigation/controllers/navigation_controller.dart

@ -1,5 +0,0 @@
import 'package:get/get.dart';
class NavigationController extends GetxController {
static NavigationController get to => Get.find(); //单例访问模式
}

17
lib/modules/navigation/views/navigation_view.dart

@ -1,17 +0,0 @@
import 'package:deep_voice/modules/navigation/controllers/navigation_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class NavigationView extends GetView<NavigationController> {
const NavigationView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('地图展示')),
body: Stack(
children: [],
),
);
}
}

7
lib/modules/pairing/controllers/pairing_controller.dart

@ -136,6 +136,8 @@ class PairingController extends GetxController {
/// 跳过配对
void skipPairing() async {
Logger.i(_tag, '用户选择跳过配对');
// 权限申请
await _checkAndRequestPermissions();
// 启动BLE后台服务
await BleManager.to.startAgentService();
Logger.i(_tag, 'BLE后台服务已启动');
@ -575,6 +577,8 @@ class PairingController extends GetxController {
if (Platform.isAndroid && await _getAndroidSdkVersion() >= 31) {
requiredPermissions.add(Permission.bluetoothScan);
requiredPermissions.add(Permission.bluetoothConnect);
requiredPermissions.add(Permission.locationAlways);
requiredPermissions.add(Permission.notification);
}
// 逐个请求权限
@ -608,6 +612,9 @@ class PairingController extends GetxController {
String _getPermissionName(Permission permission) {
if (permission == Permission.location) return '定位';
if (permission == Permission.locationWhenInUse) return '定位';
if (permission == Permission.locationAlways) return '定位';
if (permission == Permission.notification) return '通知';
if (permission == Permission.accessNotificationPolicy) return '通知';
if (permission == Permission.bluetoothScan) return '蓝牙扫描';
if (permission == Permission.bluetoothConnect) return '蓝牙连接';
return '未知权限';

8
lib/modules/pairing/views/pairing_view.dart

@ -17,10 +17,10 @@ class PairingView extends GetView<PairingController> {
body: SafeArea(
child: _buildMainContent(isDarkMode),
),
bottomNavigationBar: Opacity(
opacity: 0.5,
child: const CommonBottomNav(currentIndex: 0),
),
// bottomNavigationBar: Opacity(
// opacity: 0.5,
// child: const CommonBottomNav(currentIndex: 0),
// ),
);
}

17
lib/routes/app_pages.dart

@ -1,4 +1,3 @@
import 'package:deep_voice/modules/navigation/views/navigation_view.dart';
import 'package:get/get.dart';
import '../modules/feedback/bindings/feedback_bindings.dart';
import '../modules/feedback/views/feedback_view.dart';
@ -9,6 +8,8 @@ import '../modules/explore/bindings/explore_binding.dart';
import '../modules/meeting/bindings/meeting_binding.dart';
import '../modules/meeting/bindings/meeting_details_binding.dart';
import '../modules/meeting/views/meeting_details_view.dart';
import '../modules/meeting/bindings/meeting_record_binding.dart';
import '../modules/meeting/views/meeting_record_view.dart';
import '../modules/meeting/views/meeting_view.dart';
import '../modules/profile/views/profile_view.dart';
import '../modules/profile/bindings/profile_binding.dart';
@ -141,6 +142,13 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.meetingRecord,
page: () => const MeetingRecordView(),
binding: MeetingRecordBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.timbre,
page: () => const TimbreView(),
@ -184,13 +192,6 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.navigation,
page: () => const NavigationView(),
binding: AgentBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.realtime,
page: () => const RealtimeView(),

2
lib/routes/app_routes.dart

@ -22,7 +22,7 @@ abstract class Routes {
// 会议相关
static const meeting = '/meeting';
static const meetingDetails = '/meeting/details';
static const meetingRecord = '/meeting/record';
// 语言功能
static const translation = '/translation'; // 实时翻译
static const translationHistory = '/translation/history'; // 翻译历史

22
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

@ -486,7 +486,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理
stopAudioProcessing()
// 停止录音
recordfile!!.closeFile()
// 会话结束事件会设置isContinuousRecognitionActive = false
return true
} catch (e: Exception) {
@ -496,7 +496,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理
stopAudioProcessing()
recordfile!!.closeFile()
// 尝试强制关闭识别器
try {
recognizer?.close()
@ -609,16 +609,30 @@ class AzureAsrHelper(private val context: Context) {
*/
fun enableRecord() {
Log.i(tag, "开启录音:")
if( recordfile!!.isPause)
{
recordfile!!.isPause=false
}
else{
recordfile!!.closeFile()
isRecording = true
if (isContinuousRecognitionActive) {
recordfile!!.creatingFiles()
}
}
}
/**
* 停止录音
*/
fun pauseRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=true
}
/**
* 停止录音
@ -626,6 +640,7 @@ class AzureAsrHelper(private val context: Context) {
fun stopRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=false
recordfile!!.closeFile()
}
@ -912,6 +927,7 @@ class AzureAsrHelper(private val context: Context) {
// 音频配置
private val sampleRate = 16000
var isPause=false
var fileName = ""
internal fun creatingFiles() {
if (fos != null || currentAudioFile != null) {

5
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -365,7 +365,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"pauseRecord" -> {
azureAsrHelper.pauseRecord()
result.success(true)
}
"stopRecord" -> {
azureAsrHelper.stopRecord()
result.success(true)

126
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -5,7 +5,6 @@ import android.bluetooth.le.*
import android.content.Context
import android.os.*
import androidx.lifecycle.MutableLiveData
import android.util.Log
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList
// Import Jieli Opus SDK classes
@ -14,6 +13,19 @@ import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import com.jieli.jl_audio_decode.exceptions.OpusException
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import android.os.Handler
import android.os.HandlerThread
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/**
* BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能
* 支持:
@ -63,7 +75,7 @@ object BleService {
private var notifyChar: BluetoothGattCharacteristic? = null
private var writeChar: BluetoothGattCharacteristic? = null
private var audioChar: BluetoothGattCharacteristic? = null
var recordfile: RecordingFile? = null
// 扫描相关
private lateinit var scanHandler: Handler
private val scanResults = ArrayList<ScanResult>()
@ -128,7 +140,7 @@ object BleService {
Log.e(TAG, "OpusManager初始化失败: ${e.message}", e)
// 根据需要决定是否因为Opus初始化失败而返回false
}
recordfile = RecordingFile(this.context)
isInitialized = true
Log.d(TAG, "BLE服务初始化成功")
return true
@ -510,6 +522,7 @@ object BleService {
when (c.uuid) {
// 音频特征数据
BleConst.RECEIVE_AUDIO_CHAR_UUID -> {
// Log.i(TAG, "收到音频特征数据")
processAudioData(data)
}
@ -588,6 +601,7 @@ object BleService {
private fun processAudioData(data: ByteArray) {
try {
if (opusManager?.isDecodeStream == true) {
recordfile?.saveAudioDataToWav(data)
opusManager?.writeAudioStream(data)
} else {
// Log.d(TAG, "Opus解码流未启动,忽略音频数据")
@ -785,7 +799,17 @@ object BleService {
BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码"
else -> "未知状态($codecStatus)"
}
if (codecStatus == BleConst.CODEC_CONTROL_DECODE_ON ||
codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY ||
codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON) {
recordfile!!.closeFile()
recordfile!!.creatingFiles()
}
else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
{
recordfile!!.closeFile()
}
// 声道模式描述
val channelDesc = when (channelMode) {
BleConst.AUDIO_CHANNEL_LEFT -> "左声道"
@ -1366,4 +1390,96 @@ object BleService {
}
}
}
}
}
class RecordingFile(private val context: Context) {
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
var fileName = ""
internal fun creatingFiles() {
if (fos != null || currentAudioFile != null) {
return
}
// 创建新的音频文件
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
val timestamp = dateFormat.format(Date())
val parentDir = File(context.filesDir, "OpusAudio")
if (!parentDir.exists()) {
parentDir.mkdirs()
}
val filePath = File(parentDir, "${fileName}_${timestamp}.opus").absolutePath
currentAudioFile = File(filePath)
currentAudioFile?.createNewFile()
// 追加音频数据到文件
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("", "异步写入音频数据失败: ${e.message}")
}
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
writeQueue.offer(buffer.copyOf())
totalBytesWritten += buffer.size
}
internal fun closeFile() {
try {
if (fos == null || currentAudioFile == null) {
return
}
isWriting.set(false)
writeThread?.join(500) // 等待写线程结束
fos?.close()
// 更新WAV文件头
currentAudioFile?.let { file ->
// 如果文件小于44字节,直接删除
if (file.length() <= 0) {
file.delete()
Log.d("TAG", "音频文件过小已删除: ${file.absolutePath}")
} else {
Log.d("TAG", "音频文件保存完成: ${file.absolutePath}")
}
}
} catch (e: Exception) {
Log.e("TAG", "更新opus失败: ${e.message}")
} finally {
fos = null
currentAudioFile = null
}
}
}

16
local_plugins/location_service/ios/location_service.podspec

@ -15,15 +15,13 @@ A new Flutter project.
s.source = { :path => '.' }
s.source_files = 'location_service/Sources/location_service/**/*'
s.dependency 'Flutter'
s.dependency 'AMapNavi', '~> 10.0.900'
s.static_framework = true
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
}
s.platform = :ios, '12.0'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
# If your plugin requires a privacy manifest, for example if it uses any
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
# plugin's privacy impact, and then uncomment this line. For more information,
# see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
# s.resource_bundles = {'location_service_privacy' => ['location_service/Sources/location_service/PrivacyInfo.xcprivacy']}
end

21
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -30,6 +30,7 @@ class MusicService : MediaBrowserServiceCompat() {
private const val TAG = "MusicService"
private const val CHANNEL_ID = "music_media_service_channel"
private const val NOTIFICATION_ID = 1002
private const val DELETE_ACTION = "com.yunqiinnovation.music_service.DELETE_NOTIFICATION"
private val isRunning = AtomicBoolean(false)
private var currentUrlInternal: String? = null
@ -145,7 +146,22 @@ class MusicService : MediaBrowserServiceCompat() {
if (currentStatus) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
)
val nextIntent = mediaButtonPendingIntent(PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
// 添加删除按钮Action
// 创建删除通知的PendingIntent
val deleteIntent = Intent(this, MusicService::class.java).apply {
action = DELETE_ACTION
}
val deletePendingIntent = PendingIntent.getService(
this,
0,
deleteIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(artist)
@ -163,7 +179,10 @@ class MusicService : MediaBrowserServiceCompat() {
playPauseIntent
)
.addAction(android.R.drawable.ic_media_next, "下一首", nextIntent)
// 添加删除通知按钮
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "删除", deletePendingIntent)
.setOngoing(currentStatus)
.setDeleteIntent(deletePendingIntent) // 设置通知被滑动删除时的行为
// 异步加载封面图
if (!coverUrl.isNullOrEmpty()) {

2
pubspec.yaml

@ -50,7 +50,7 @@ dependencies:
device_info_plus: ^10.1.2
uuid: ^4.3.3
record: ^5.2.1
share_plus: ^10.0.0
share_plus: ^11.0.0
image_picker: ^1.1.2
audio_waveforms: ^1.3.0
font_awesome_flutter: ^10.8.0

Loading…
Cancel
Save