From dc7ed336406b4b98b14a6e2a1a61eaac37a3d648 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 14 Jan 2026 11:51:01 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(=E6=82=AC=E6=B5=AE=E7=AA=97):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=82=B9=E5=87=BB=E6=82=AC=E6=B5=AE=E7=AA=97?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=BF=BB=E8=AF=91=E9=A1=B5=E9=9D=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现悬浮窗语言区域点击返回翻译页面功能,并记录上次翻译模式 - 在Android平台下记录当前翻译模式 - 点击悬浮窗语言区域时返回翻译页面并恢复上次模式 - 添加模式存储和读取功能 --- .../services/floating_window_manager.dart | 40 ++++++++++++++++--- .../controllers/translation_controller.dart | 6 +++ .../native_plugin/FloatingWindowService.kt | 21 ++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/lib/core/services/floating_window_manager.dart b/lib/core/services/floating_window_manager.dart index 47f347483..056425701 100644 --- a/lib/core/services/floating_window_manager.dart +++ b/lib/core/services/floating_window_manager.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:floating_ui_plugin/native_plugin.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import '../utils/logger.dart'; import '../utils/overlay_permission_util.dart'; +import '../../routes/app_routes.dart'; +import '../../modules/translation/controllers/translation_controller.dart'; /// 悬浮窗状态 enum FloatingWindowState { @@ -48,6 +51,9 @@ class FloatingWindowManager extends GetxController { static MethodChannel get _channel => TranslationFloatingWindowApi.channel; final TranslationFloatingWindowApi _api = TranslationFloatingWindowApi(); + static const String _lastTranslationModeKey = + 'floating_window_last_translation_mode'; + final GetStorage _storage = GetStorage(); final _isEnabled = false.obs; final _currentState = FloatingWindowState.hidden.obs; @@ -79,7 +85,7 @@ class FloatingWindowManager extends GetxController { break; case 'onFloatingWindowClicked': // 悬浮窗被点击,可以执行相应操作(如返回主应用) - _handleFloatingWindowClick(); + await _handleFloatingWindowClick(); break; } }); @@ -194,9 +200,33 @@ class FloatingWindowManager extends GetxController { } /// 处理悬浮窗点击事件 - void _handleFloatingWindowClick() { - // 这里可以实现点击悬浮窗后的操作,比如返回主应用 - Logger.info('悬浮窗被点击'); - // 可以发送事件给其他组件处理 + Future _handleFloatingWindowClick() async { + final String mode = getLastTranslationMode(); + Logger.info('悬浮窗语言区域被点击,返回翻译页面: $mode'); + + if (Get.isRegistered()) { + final controller = Get.find(); + if (controller.currentMode.value != mode) { + await controller.changeTranslationMode(mode); + } + } + + if (Get.currentRoute != Routes.translation) { + Get.toNamed(Routes.translation, arguments: {'translationId': mode}); + } + } + + void setLastTranslationMode(String mode) { + _storage.write(_lastTranslationModeKey, _normalizeTranslationMode(mode)); + } + + String getLastTranslationMode() { + final String? mode = _storage.read(_lastTranslationModeKey); + return _normalizeTranslationMode(mode); + } + + String _normalizeTranslationMode(String? mode) { + if (mode == 'audioVideo' || mode == 'call') return mode!; + return 'call'; } } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index af6a8302e..56d64f1d2 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -315,6 +315,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { loadTranslationHistory(); //stopAll(); + if (Platform.isAndroid && (mode == 'call' || mode == 'audioVideo')) { + try { + _floatingWindowManager.setLastTranslationMode(mode); + } catch (_) {} + } + // 检查悬浮窗支持情况 if (isFloatingWindowEnabled.value && !_isFloatingWindowSupportedMode()) { // 当前模式不支持悬浮窗,自动禁用 diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt index 9fb2e5266..760c12060 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt @@ -459,6 +459,11 @@ class FloatingWindowService : Service() { stopSelf() notifyClosed() } + + languageInfoView?.setOnClickListener { + bringAppToFront() + notifyClicked() + } } private fun setupDragListener() { @@ -492,6 +497,9 @@ class FloatingWindowService : Service() { if (!isMinimized && isTouchingView(resizeHandleView, event.rawX, event.rawY)) { return false } + if (!isMinimized && isTouchingView(languageInfoView, event.rawX, event.rawY)) { + return false + } dragging = false scaling = false @@ -684,6 +692,19 @@ class FloatingWindowService : Service() { channel.invokeMethod("onFloatingWindowClicked", null) } + private fun bringAppToFront() { + try { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?: return + launchIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + ) + startActivity(launchIntent) + } catch (_: Exception) { + } + } + private fun dp(value: Int): Int { return (value * resources.displayMetrics.density).toInt() } From 572d0ec0eee36a35934e6b3b9a4fe55cc361e730 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 14 Jan 2026 16:36:56 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(=E5=88=86=E4=BA=AB):=20Android?= =?UTF-8?q?=E4=BE=A7=E6=B7=BB=E5=8A=A0=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=92=8C=E6=89=93=E5=BC=80=E4=BD=BF=E7=94=A8?= =?UTF-8?q?Voitrans/meeting=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现通过系统分享或文件打开方式将音频文件传递到应用的功能 在Android端添加相关intent-filter和处理逻辑 在Flutter端添加文件接收和处理逻辑 添加文件缓存和重试机制确保处理可靠性 --- android/app/src/main/AndroidManifest.xml | 14 +++ .../com/saitong/voitrans/MainActivity.kt | 103 +++++++++++++++++- ...VoitransShareExtensionProfile.entitlements | 10 ++ lib/main.dart | 43 ++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4e3d6a8c6..e82e00f29 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -111,6 +111,20 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt b/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt index 2a992b381..e11ffb7eb 100644 --- a/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt +++ b/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt @@ -15,11 +15,16 @@ import android.os.Looper import android.util.Log import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.plugin.common.MethodChannel // 添加这行导入 +import android.net.Uri +import android.provider.OpenableColumns +import java.io.File +import java.io.FileOutputStream class MainActivity: FlutterActivity() { private val TAG = "MainActivity" private var pendingDeepLink: Intent? = null private var pendingNavigationIntent: Intent? = null + private var pendingShareIntent: Intent? = null private val EXTRA_NAVIGATE_TO = "com.saitong.voitrans.extra.NAVIGATE_TO" private val NAVIGATE_TO_MEETING_RECORD = "meetingRecord" @@ -36,10 +41,20 @@ class MainActivity: FlutterActivity() { Log.d(TAG, "Intent extras: ${it.extras}") } - // 暂存深链接,等Flutter引擎完全准备好后处理 - if (intent?.action == Intent.ACTION_VIEW && intent?.data != null) { - pendingDeepLink = intent - Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成") + intent?.let { + if (it.action == Intent.ACTION_SEND) { + pendingShareIntent = it + Log.d(TAG, "📎 暂存分享文件意图,等待Flutter引擎准备完成") + } else if (it.action == Intent.ACTION_VIEW && it.data != null) { + val uri = it.data + if (uri != null && isLikelyAudio(it, uri)) { + pendingShareIntent = it + Log.d(TAG, "📎 暂存打开文件意图,等待Flutter引擎准备完成") + } else { + pendingDeepLink = it + Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成") + } + } } if (intent?.hasExtra(EXTRA_NAVIGATE_TO) == true) { @@ -110,6 +125,12 @@ class MainActivity: FlutterActivity() { // Flutter引擎配置完成后,处理待处理的深链接 Handler(Looper.getMainLooper()).postDelayed({ + pendingShareIntent?.let { intent -> + Log.d(TAG, "📎 处理待处理的分享/打开文件意图") + handleNavigationIntent(intent) + pendingShareIntent = null + } + pendingDeepLink?.let { intent -> Log.d(TAG, "🔗 处理待处理的深链接") handleNavigationIntent(intent) @@ -135,6 +156,21 @@ class MainActivity: FlutterActivity() { return } + if (it.action == Intent.ACTION_SEND || it.action == Intent.ACTION_VIEW) { + try { + val path = extractAudioPathFromIntent(it) + if (path != null) { + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + val channel = MethodChannel(messenger, "com.saitong.voitrans/deeplink") + channel.invokeMethod("handleSharedFile", mapOf("filePath" to path)) + return + } + } + } catch (e: Exception) { + Log.e(TAG, "❌ 处理分享/打开文件失败", e) + } + } + if (it.action == Intent.ACTION_VIEW && it.data != null) { val uri = it.data Log.d(TAG, "🔗 处理深链接: $uri") @@ -169,6 +205,65 @@ class MainActivity: FlutterActivity() { } } + private fun extractAudioPathFromIntent(intent: Intent): String? { + val uri: Uri? = when (intent.action) { + Intent.ACTION_SEND -> intent.getParcelableExtra(Intent.EXTRA_STREAM) + Intent.ACTION_VIEW -> intent.data + else -> null + } + if (uri == null) return null + if (!isLikelyAudio(intent, uri)) return null + return copyUriToCache(uri) + } + + private fun isLikelyAudio(intent: Intent, uri: Uri): Boolean { + val type = intent.type ?: "" + if (type.startsWith("audio/")) return true + val scheme = uri.scheme ?: "" + if (scheme == "content" || scheme == "file") return true + val path = uri.toString().lowercase() + return path.endsWith(".wav") || path.endsWith(".mp3") || path.endsWith(".m4a") || path.endsWith(".aac") + } + + private fun copyUriToCache(uri: Uri): String? { + return try { + val resolver = applicationContext.contentResolver + val cursor = resolver.query(uri, null, null, null, null) + var displayName = "shared_audio" + cursor?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && it.moveToFirst()) { + displayName = it.getString(nameIndex) ?: displayName + } + } + val ext = when { + displayName.endsWith(".wav", true) -> "wav" + displayName.endsWith(".mp3", true) -> "mp3" + displayName.endsWith(".m4a", true) -> "m4a" + displayName.endsWith(".aac", true) -> "aac" + else -> displayName.substringAfterLast('.', "audio") + } + val sharedDir = File(applicationContext.filesDir, "shared_audio") + if (!sharedDir.exists()) sharedDir.mkdirs() + val outFile = File(sharedDir, "audio_${System.currentTimeMillis()}.$ext") + resolver.openInputStream(uri)?.use { inStream -> + FileOutputStream(outFile).use { out -> + val buffer = ByteArray(8192) + var len: Int + while (true) { + len = inStream.read(buffer) + if (len <= 0) break + out.write(buffer, 0, len) + } + } + } + outFile.absolutePath + } catch (e: Exception) { + Log.e(TAG, "复制分享文件到缓存失败: ${e.message}", e) + null + } + } + private fun pushRouteToFlutter(route: String) { try { val engine = flutterEngine diff --git a/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements b/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements new file mode 100644 index 000000000..4f50aba57 --- /dev/null +++ b/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.saitong.voitrans + + + diff --git a/lib/main.dart b/lib/main.dart index 001c35645..ac646c1fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -21,6 +22,8 @@ import 'data/services/login_service.dart'; import 'dart:ui' as ui; import 'modules/splash/controllers/splash_controller.dart'; +bool _deepLinkHandlerInitialized = false; + void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -46,6 +49,9 @@ void main() async { // 初始化深链接处理器 void _initDeepLinkHandler() { + if (_deepLinkHandlerInitialized) return; + _deepLinkHandlerInitialized = true; + print("🔗 初始化深链接处理器"); const platform = MethodChannel('com.saitong.voitrans/deeplink'); @@ -62,6 +68,15 @@ void _initDeepLinkHandler() { print("🔗 处理深链接 URL: $url"); await _handleDeepLinkNavigation(url); } + return; + } + + if (call.method == 'handleSharedFile') { + final arguments = call.arguments as Map; + final filePath = arguments['filePath'] as String?; + if (filePath == null || filePath.isEmpty) return; + await _handleSharedAudioFile(filePath); + return; } }); @@ -107,6 +122,34 @@ Future _handleDeepLinkNavigation(String url) async { } } +Future _handleSharedAudioFile(String filePath) async { + try { + if (!File(filePath).existsSync()) { + print("❌ 分享文件不存在: $filePath"); + return; + } + + int retryCount = 0; + const maxRetries = 10; + while (retryCount < maxRetries) { + try { + Get.toNamed( + Routes.meeting, + arguments: { + 'filePath': filePath, + }, + ); + return; + } catch (e) { + retryCount++; + await Future.delayed(const Duration(milliseconds: 300)); + } + } + } catch (e) { + print("❌ 处理分享文件失败: $e"); + } +} + class MainApp extends StatelessWidget { const MainApp({super.key}); From 49465ef4c3d30f0d36999f99e10e7353a8ec6c3e Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 14 Jan 2026 19:35:58 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting_details_controller.dart | 12 +- .../views/bottomSheet/more_bottom_sheet.dart | 3 +- .../views/bottomSheet/share_bottom_sheet.dart | 18 +- .../meeting/views/meeting_home_view.dart | 6 + .../meeting/views/tabs/overview_tab.dart | 54 +- .../meeting/views/tabs/speech_tab.dart | 479 ++++++++++-------- .../meeting/views/tabs/summary_tab.dart | 74 ++- 7 files changed, 413 insertions(+), 233 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index aa2e1f4a3..272d02609 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -57,7 +57,7 @@ class MeetingDetailsController extends GetxController RxInt editSpeakerTextId = 0.obs; String editSpeakerTextChange = ''; - int _everType = 99; + int _everType = 99999; RxInt summaryNumberModifications = 0.obs; RxInt personnelNumberModifications = 0.obs; @@ -94,12 +94,12 @@ class MeetingDetailsController extends GetxController _tasktypeListener = ever(_meetingTaskService.tasktype, (value) { meetingDetails.value.tasktype = value; if (_everType <= value) { - if (value >= 5) { + if (value >= 5 && value < 10000) { meetingDetails.value.personnel = _meetingTaskService.personnel; meetingDetails.value.summary = _meetingTaskService.summary; meetingDetails.value.overview = _meetingTaskService.overview; summaryNumberModifications.value++; - } else if (value >= 3) { + } else if (value >= 3 && value < 10000) { textList.value = _meetingTaskService.textList; } } @@ -132,14 +132,16 @@ class MeetingDetailsController extends GetxController if (meetingDetails.value.tasktype == 5) { await _meetingTaskService.readTask(id); } - if (meetingDetails.value.tasktype >= 5) { + if (meetingDetails.value.tasktype >= 5 && + meetingDetails.value.tasktype < 10000) { dragOffset.value = 0; isTop.value = true; } if (meetingData.value.filepath.isNotEmpty) { await _loadPlayerController(); } - if (meetingDetails.value.tasktype >= 3) { + if (meetingDetails.value.tasktype >= 3 && + meetingDetails.value.tasktype < 10000) { List speakerList = await SqfliteApi.getMeetingSpeaker(id); textList.value = List.from(speakerList); } diff --git a/lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart index 43335adfe..1701f6c76 100644 --- a/lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart @@ -26,7 +26,8 @@ class _MoreBottomSheetState extends State { children: [ SizedBox(height: 10.w), _shareItem(Icons.refresh, 'reSummarize'.tr, //重新总结 - onTap: _detailsController.meetingDetails.value.tasktype >= 5 + onTap: _detailsController.meetingDetails.value.tasktype >= 5 && + _detailsController.meetingDetails.value.tasktype < 10002 ? () { Get.back(); Get.bottomSheet( diff --git a/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart index 08a76eebf..f4d507a34 100644 --- a/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart @@ -117,7 +117,8 @@ class _ShareBottomSheetState extends State { ]), _box([ _shareItem(Icons.copy, 'copyTranscript'.tr, // 对应中文:复制转写 - onTap: _controller.meetingDetails.value.tasktype >= 3 + onTap: _controller.meetingDetails.value.tasktype >= 3 && + _controller.meetingDetails.value.tasktype < 10002 ? () { Get.back(); _copy(_getAllText()); @@ -125,7 +126,8 @@ class _ShareBottomSheetState extends State { : null), _shareItem(Icons.copy, 'copySummary'.tr, // 对应中文:复制总结 isBorder: false, - onTap: _controller.meetingDetails.value.tasktype >= 5 + onTap: _controller.meetingDetails.value.tasktype >= 5 && + _controller.meetingDetails.value.tasktype < 10001 ? () { Get.back(); _copy(_markdownToPlainText( @@ -142,7 +144,8 @@ class _ShareBottomSheetState extends State { 'shareMeetingAudio'.tr); // 对应中文:分享会议音频 }), _shareItem(Icons.message, 'exportTranscript'.tr, // 对应中文:导出转写 - onTap: _controller.meetingDetails.value.tasktype >= 3 + onTap: _controller.meetingDetails.value.tasktype >= 3 && + _controller.meetingDetails.value.tasktype < 10002 ? () async { Get.back(); final tempDir = await getTemporaryDirectory(); @@ -155,7 +158,8 @@ class _ShareBottomSheetState extends State { } : null), _shareItem(Icons.article, 'exportSummary'.tr, // 对应中文:导出总结 - onTap: _controller.meetingDetails.value.tasktype >= 5 + onTap: _controller.meetingDetails.value.tasktype >= 5 && + _controller.meetingDetails.value.tasktype < 10001 ? () async { Get.back(); Get.bottomSheet( @@ -205,7 +209,8 @@ class _ShareBottomSheetState extends State { _shareItem( Icons.save_alt, '保存思维导图', - onTap: _controller.meetingDetails.value.tasktype >= 5 + onTap: _controller.meetingDetails.value.tasktype >= 5 && + _controller.meetingDetails.value.tasktype < 10001 ? () { Get.back(); final mindMapController = @@ -233,7 +238,8 @@ class _ShareBottomSheetState extends State { Icons.lan, 'shareMindMap'.tr, // 对应中文:分享思维导图 isBorder: false, - onTap: _controller.meetingDetails.value.tasktype >= 5 + onTap: _controller.meetingDetails.value.tasktype >= 5 && + _controller.meetingDetails.value.tasktype < 10001 ? () { Get.back(); final mindMapController = diff --git a/lib/modules/meeting/views/meeting_home_view.dart b/lib/modules/meeting/views/meeting_home_view.dart index c15a3f6bf..c754175cf 100644 --- a/lib/modules/meeting/views/meeting_home_view.dart +++ b/lib/modules/meeting/views/meeting_home_view.dart @@ -267,6 +267,12 @@ class _MeetingHomeViewState extends State case 5: tasktypeText = 'summaryComplete'.tr; // 对应中文:总结完成 break; + case 10001: + tasktypeText = '总结失败'; + break; + case 10002: + tasktypeText = '转写失败'; + break; default: tasktypeText = ''; } diff --git a/lib/modules/meeting/views/tabs/overview_tab.dart b/lib/modules/meeting/views/tabs/overview_tab.dart index c43201d9d..69057d32d 100644 --- a/lib/modules/meeting/views/tabs/overview_tab.dart +++ b/lib/modules/meeting/views/tabs/overview_tab.dart @@ -99,7 +99,8 @@ class _OverviewTabState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (_controller.meetingDetails.value.tasktype >= 3) + if (_controller.meetingDetails.value.tasktype >= 3 && + _controller.meetingDetails.value.tasktype < 10000) _button( Icons.auto_awesome, 'Ask AI', @@ -138,6 +139,50 @@ class _OverviewTabState extends State { }, ), ), + if (_controller.meetingDetails.value.tasktype == 10002) + SizedBox( + width: 150.w, + child: _button( + Icons.auto_awesome, + '重新生成', + () { + Get.bottomSheet( + const GenerateBottomSheet(), + isScrollControlled: true, + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), + ), + if (_controller.meetingDetails.value.tasktype == 10001) + SizedBox( + width: 150.w, + child: _button( + Icons.auto_awesome, + '重新总结', + () { + Get.bottomSheet( + const GenerateBottomSheet( + isSummarizeAgain: true, + ), + isScrollControlled: true, + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), + ), ], ), ), @@ -200,7 +245,8 @@ class _OverviewTabState extends State { Widget _empty() { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Padding( + return Container( + width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 30.w), child: Column( children: [ @@ -221,7 +267,9 @@ class _OverviewTabState extends State { ), ), Text( - 'aiAnalysisDesc'.tr, // 对应中文:AI分析识别录音中的关键事件,让你选择重要事件进行AI生成。 + _controller.meetingDetails.value.tasktype < 10000 + ? 'aiAnalysisDesc'.tr // 对应中文:AI分析识别录音中的关键事件,让你选择重要事件进行AI生成。 + : '分析失败,请稍后重试', style: TextStyle( color: MeetingUIUtils.getSecondaryTextColor(isDarkMode), fontSize: 14.sp, diff --git a/lib/modules/meeting/views/tabs/speech_tab.dart b/lib/modules/meeting/views/tabs/speech_tab.dart index b42d9af49..43acf27cc 100644 --- a/lib/modules/meeting/views/tabs/speech_tab.dart +++ b/lib/modules/meeting/views/tabs/speech_tab.dart @@ -20,229 +20,259 @@ class SpeechTab extends GetView { return Stack( children: [ Obx( - () => ListView.builder( - itemCount: controller.textList.length, - padding: EdgeInsets.only(bottom: 80.w), - itemBuilder: (BuildContext context, int index) { - Map item = controller.textList[index]; - TextStyle textStyle = TextStyle( - fontSize: 15.sp, - color: MeetingUIUtils.getTextColor(isDarkMode), - letterSpacing: 0, - height: 1.4, - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(top: 10.w), - child: Row( + () => controller.meetingDetails.value.tasktype != 10002 + ? ListView.builder( + itemCount: controller.textList.length, + padding: EdgeInsets.only(bottom: 80.w), + itemBuilder: (BuildContext context, int index) { + Map item = controller.textList[index]; + TextStyle textStyle = TextStyle( + fontSize: 15.sp, + color: MeetingUIUtils.getTextColor(isDarkMode), + letterSpacing: 0, + height: 1.4, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (item['speaker'].isNotEmpty) - Container( - width: 11.w, - height: 11.w, - margin: EdgeInsets.only(right: 3.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(11.r), - border: Border.all( - color: const Color(0xFFFFC107), - width: 3.w, - ), - ), - ), - if (item['speaker'].isNotEmpty) - GestureDetector( - onTap: () { - if (controller.isSpeakerText.value) return; - Get.bottomSheet( - SpeakerBottomSheet(item['id'], item['speaker']), - isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), + Padding( + padding: EdgeInsets.only(top: 10.w), + child: Row( + children: [ + if (item['speaker'].isNotEmpty) + Container( + width: 11.w, + height: 11.w, + margin: EdgeInsets.only(right: 3.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11.r), + border: Border.all( + color: const Color(0xFFFFC107), + width: 3.w, + ), ), ), - ); - }, - child: Padding( - padding: EdgeInsets.only(right: 5.w), - child: Text( - item['speaker'], + if (item['speaker'].isNotEmpty) + GestureDetector( + onTap: () { + if (controller.isSpeakerText.value) return; + Get.bottomSheet( + SpeakerBottomSheet( + item['id'], item['speaker']), + isScrollControlled: true, + backgroundColor: isDarkMode + ? Colors.grey[800] + : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + child: Padding( + padding: EdgeInsets.only(right: 5.w), + child: Text( + item['speaker'], + style: TextStyle( + color: const Color(0xFFFFC107), + fontSize: 13.sp, + ), + ), + ), + ), + Text( + DateUtil.formatDateMs( + item['starttime'], + format: 'HH:mm:ss', + isUtc: true, + ), style: TextStyle( - color: const Color(0xFFFFC107), + color: MeetingUIUtils.getSecondaryTextColor( + isDarkMode), fontSize: 13.sp, ), ), - ), - ), - Text( - DateUtil.formatDateMs( - item['starttime'], - format: 'HH:mm:ss', - isUtc: true, - ), - style: TextStyle( - color: MeetingUIUtils.getSecondaryTextColor(isDarkMode), - fontSize: 13.sp, + Obx(() { + bool isActive = false; + int starttime = item['starttime']; + int endtime = 0; + if (index == controller.textList.length - 1) { + endtime = controller.durationMax.value; + } else { + endtime = controller.textList[index + 1] + ['starttime']; + } + if (controller.currentDuration.value != 0) { + if (controller.currentDuration.value >= + starttime && + controller.currentDuration.value < + endtime) { + isActive = true; + } + } + return isActive + ? Container( + width: 16.w, + height: 16.w, + margin: EdgeInsets.only(left: 5.w), + alignment: Alignment.center, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Colors.red[100], + borderRadius: + BorderRadius.circular(4.r), + ), + child: Transform.scale( + scale: 1.6, + child: Lottie.asset( + 'assets/lottie/sound_effect.json', + controller: + controller.lottieController, + ), + ), + ) + : const SizedBox(); + }), + ], ), ), Obx(() { - bool isActive = false; - int starttime = item['starttime']; - int endtime = 0; - if (index == controller.textList.length - 1) { - endtime = controller.durationMax.value; + if (controller.isSpeakerText.value) { + TextEditingController speakerTextController = + TextEditingController(text: item['content']); + return Obx( + () => Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + ), + decoration: BoxDecoration( + color: controller.editSpeakerTextId.value == + item['id'] + ? Colors.blue[100] + : null, + borderRadius: BorderRadius.circular(8.w), + ), + child: FocusScope( + onFocusChange: (bool hasFocus) { + if (hasFocus) { + controller.editSpeakerTextId.value = + item['id']; + controller.editSpeakerTextChange = ''; + } else { + if (speakerTextController.text != + item['content']) { + Map? foundElement = controller + .editSpeakerTextList + .firstWhere( + (element) => + element['id'] == item['id'], + orElse: () => null, + ); + if (foundElement != null) { + foundElement['content'] = + speakerTextController.text; + } else { + controller.editSpeakerTextList.add({ + 'id': item['id'], + 'content': + speakerTextController.text, + }); + } + } + } + }, + child: TextField( + controller: speakerTextController, + style: textStyle, + minLines: 1, + maxLines: 100, + decoration: InputDecoration( + isDense: true, + fillColor: Colors.transparent, + border: InputBorder.none, + contentPadding: + EdgeInsets.symmetric(vertical: 5.w), + ), + onChanged: (String value) { + controller.editSpeakerTextChange = value; + }, + ), + ), + ), + ); } else { - endtime = - controller.textList[index + 1]['starttime']; - } - if (controller.currentDuration.value != 0) { - if (controller.currentDuration.value >= starttime && - controller.currentDuration.value < endtime) { - isActive = true; - } - } - return isActive - ? Container( - width: 16.w, - height: 16.w, - margin: EdgeInsets.only(left: 5.w), - alignment: Alignment.center, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: Colors.red[100], - borderRadius: BorderRadius.circular(4.r), + return Obx(() { + bool isActive = false; + int starttime = item['starttime']; + int endtime = 0; + if (index == controller.textList.length - 1) { + endtime = controller.durationMax.value; + } else { + endtime = + controller.textList[index + 1]['starttime']; + } + if (controller.currentDuration.value != 0) { + if (controller.currentDuration.value >= + starttime && + controller.currentDuration.value < + endtime) { + isActive = true; + } + } + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (isActive) { + if (controller.isPlay.value) { + controller.playerController.pausePlayer(); + } else { + controller.playerController.startPlayer(); + } + } else { + controller.playerController + .seekTo(item['starttime']); + controller.playerController.startPlayer(); + } + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 5.w, ), - child: Transform.scale( - scale: 1.6, - child: Lottie.asset( - 'assets/lottie/sound_effect.json', - controller: controller.lottieController, + child: Text( + item['content'], + style: TextStyle( + fontSize: 15.sp, + color: isActive + ? Colors.red + : MeetingUIUtils.getTextColor( + isDarkMode), + letterSpacing: 0, + height: 1.4, ), ), - ) - : const SizedBox(); + ), + ); + }); + } }), ], + ); + }, + ) + : Container( + height: 200.w, + alignment: Alignment.center, + child: Text( + '转写失败,请稍后重试', + style: TextStyle( + color: MeetingUIUtils.getSecondaryTextColor(isDarkMode), + fontSize: 14.sp, ), ), - Obx(() { - if (controller.isSpeakerText.value) { - TextEditingController speakerTextController = - TextEditingController(text: item['content']); - return Obx( - () => Container( - padding: EdgeInsets.symmetric( - horizontal: 14.w, - ), - decoration: BoxDecoration( - color: - controller.editSpeakerTextId.value == item['id'] - ? Colors.blue[100] - : null, - borderRadius: BorderRadius.circular(8.w), - ), - child: FocusScope( - onFocusChange: (bool hasFocus) { - if (hasFocus) { - controller.editSpeakerTextId.value = item['id']; - controller.editSpeakerTextChange = ''; - } else { - if (speakerTextController.text != - item['content']) { - Map? foundElement = - controller.editSpeakerTextList.firstWhere( - (element) => element['id'] == item['id'], - orElse: () => null, - ); - if (foundElement != null) { - foundElement['content'] = - speakerTextController.text; - } else { - controller.editSpeakerTextList.add({ - 'id': item['id'], - 'content': speakerTextController.text, - }); - } - } - } - }, - child: TextField( - controller: speakerTextController, - style: textStyle, - minLines: 1, - maxLines: 100, - decoration: InputDecoration( - isDense: true, - fillColor: Colors.transparent, - border: InputBorder.none, - contentPadding: - EdgeInsets.symmetric(vertical: 5.w), - ), - onChanged: (String value) { - controller.editSpeakerTextChange = value; - }, - ), - ), - ), - ); - } else { - return Obx(() { - bool isActive = false; - int starttime = item['starttime']; - int endtime = 0; - if (index == controller.textList.length - 1) { - endtime = controller.durationMax.value; - } else { - endtime = controller.textList[index + 1]['starttime']; - } - if (controller.currentDuration.value != 0) { - if (controller.currentDuration.value >= starttime && - controller.currentDuration.value < endtime) { - isActive = true; - } - } - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - if (isActive) { - if (controller.isPlay.value) { - controller.playerController.pausePlayer(); - } else { - controller.playerController.startPlayer(); - } - } else { - controller.playerController - .seekTo(item['starttime']); - controller.playerController.startPlayer(); - } - }, - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: 14.w, - vertical: 5.w, - ), - child: Text( - item['content'], - style: TextStyle( - fontSize: 15.sp, - color: isActive ? Colors.red : MeetingUIUtils.getTextColor(isDarkMode), - letterSpacing: 0, - height: 1.4, - ), - ), - ), - ); - }); - } - }), - ], - ); - }, - ), + ), ), Obx( () => Positioned( @@ -253,7 +283,8 @@ class SpeechTab extends GetView { mainAxisAlignment: MainAxisAlignment.center, children: [ if (controller.meetingDetails.value.tasktype >= 2 && - !controller.isSpeakerText.value) + !controller.isSpeakerText.value && + controller.meetingDetails.value.tasktype != 10002) _button( Icons.edit, 'editSpeaker'.tr, // 对应中文:编辑 @@ -264,7 +295,8 @@ class SpeechTab extends GetView { }, ), if (controller.meetingDetails.value.tasktype >= 2 && - !controller.isSpeakerText.value) + !controller.isSpeakerText.value && + controller.meetingDetails.value.tasktype != 10002) _button( Icons.supervisor_account, 'nameSpeaker'.tr, // 对应中文:命名发言者 @@ -272,7 +304,8 @@ class SpeechTab extends GetView { Get.bottomSheet( const SpeakerAllBottomSheet(), isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), @@ -282,7 +315,8 @@ class SpeechTab extends GetView { }, ), if (controller.meetingDetails.value.tasktype >= 2 && - !controller.isSpeakerText.value) + !controller.isSpeakerText.value && + controller.meetingDetails.value.tasktype != 10002) _button( Icons.auto_awesome, 'Ask AI', @@ -290,7 +324,8 @@ class SpeechTab extends GetView { Get.bottomSheet( const AIBottomSheet(), isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.white, + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), @@ -309,7 +344,29 @@ class SpeechTab extends GetView { Get.bottomSheet( const GenerateBottomSheet(), isScrollControlled: true, - backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), + ), + if (controller.meetingDetails.value.tasktype == 10002) + SizedBox( + width: 150.w, + child: _button( + Icons.auto_awesome, + '重新生成', + () { + Get.bottomSheet( + const GenerateBottomSheet(), + isScrollControlled: true, + backgroundColor: + isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), diff --git a/lib/modules/meeting/views/tabs/summary_tab.dart b/lib/modules/meeting/views/tabs/summary_tab.dart index 995e18d42..a64103edc 100644 --- a/lib/modules/meeting/views/tabs/summary_tab.dart +++ b/lib/modules/meeting/views/tabs/summary_tab.dart @@ -8,6 +8,7 @@ import 'package:parchment/codecs.dart'; import '../../../../data/services/db/sqflite_api.dart'; import '../../../../data/services/network/api.dart'; import '../../controllers/meeting_details_controller.dart'; +import '../../utils/meeting_ui_utils.dart'; import '../bottomSheet/ai_bottom_sheet.dart'; import '../bottomSheet/generate_bottom_sheet.dart'; import '../bottomSheet/speaker_all_bottom_sheet.dart'; @@ -335,11 +336,22 @@ class _SummaryTabState extends State { ), Padding( padding: EdgeInsets.only(top: 10.w), - child: FleatherEditor( - controller: _fleatherController!, - focusNode: _focusNode, - editorKey: _editorKey, - ), + child: _controller.meetingDetails.value.tasktype < 10000 + ? FleatherEditor( + controller: _fleatherController!, + focusNode: _focusNode, + editorKey: _editorKey, + ) + : Center( + child: Text( + '总结失败,请稍后重试', + style: TextStyle( + color: MeetingUIUtils.getSecondaryTextColor( + isDarkMode), + fontSize: 14.sp, + ), + ), + ), ), ], ), @@ -354,7 +366,8 @@ class _SummaryTabState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (_controller.meetingDetails.value.tasktype >= 3) + if (_controller.meetingDetails.value.tasktype >= 3 && + _controller.meetingDetails.value.tasktype < 10000) _button( Icons.supervisor_account, 'editSpeaker'.tr, // 对应中文:编辑发言者 @@ -372,7 +385,8 @@ class _SummaryTabState extends State { ); }, ), - if (_controller.meetingDetails.value.tasktype >= 3) + if (_controller.meetingDetails.value.tasktype >= 3 && + _controller.meetingDetails.value.tasktype < 10000) _button( Icons.auto_awesome, 'Ask AI', @@ -412,6 +426,52 @@ class _SummaryTabState extends State { }, ), ), + if (_controller.meetingDetails.value.tasktype == 10002) + SizedBox( + width: 150.w, + child: _button( + Icons.auto_awesome, + '重新生成', + () { + Get.bottomSheet( + const GenerateBottomSheet(), + isScrollControlled: true, + backgroundColor: isDarkMode + ? Colors.grey[800] + : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), + ), + if (_controller.meetingDetails.value.tasktype == 10001) + SizedBox( + width: 150.w, + child: _button( + Icons.auto_awesome, + '重新总结', + () { + Get.bottomSheet( + const GenerateBottomSheet( + isSummarizeAgain: true, + ), + isScrollControlled: true, + backgroundColor: isDarkMode + ? Colors.grey[800] + : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), + ), ], ), ), From c033ccae8dfa4190fa78d21bc7e0f06fc38c713e Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 14 Jan 2026 20:14:05 +0800 Subject: [PATCH 04/15] =?UTF-8?q?chore(ios):=20=E5=88=A0=E9=99=A4=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9C=80=E8=A6=81=E7=9A=84VoitransShareExtensionProfi?= =?UTF-8?q?le.entitlements=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VoitransShareExtensionProfile.entitlements | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements diff --git a/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements b/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements deleted file mode 100644 index 4f50aba57..000000000 --- a/ios/VoitransShareExtension/VoitransShareExtensionProfile.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.saitong.voitrans - - - From d4e7ae159964fd7705223bc40a1c6b2795a1191a Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 15 Jan 2026 19:29:19 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat(PlayerView):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E8=A7=86=E5=9B=BE=E7=9A=84UI?= =?UTF-8?q?=E5=92=8C=E5=8A=A8=E6=80=81=E6=96=87=E6=9C=AC=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新背景颜色、阴影和边框样式 - 重构语言标签布局,添加容器视图 - 实现动态文本行数限制以适应不同高度 - 优化字体颜色和行间距 --- .../ios/Classes/PlayerView.swift | 129 +++++++++++++++--- 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.swift b/local_plugins/native_plugin/ios/Classes/PlayerView.swift index 8b2b4cf77..98e624768 100644 --- a/local_plugins/native_plugin/ios/Classes/PlayerView.swift +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.swift @@ -3,9 +3,14 @@ import UIKit public class PlayerView: UIView { private let titleLabel: UILabel = UILabel() private let langLabel: UILabel = UILabel() + private let langContainer: UIView = UIView() private let cnLabel: UILabel = UILabel() private let enLabel: UILabel = UILabel() private let stackView: UIStackView + private var lastDynamicTextLines: Int = -1 + private let langRow: UIStackView = UIStackView() + private var lastBoundsSize: CGSize = .zero + private var lastLangText: String? = nil public override init(frame: CGRect) { stackView = UIStackView() @@ -24,42 +29,84 @@ public class PlayerView: UIView { } private func setupUI() { - backgroundColor = .white + backgroundColor = UIColor(red: 40.0 / 255.0, green: 40.0 / 255.0, blue: 40.0 / 255.0, alpha: 90.0 / 255.0) + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = UIColor(white: 1.0, alpha: 46.0 / 255.0).cgColor + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.35 + layer.shadowRadius = 12 + layer.shadowOffset = CGSize(width: 0, height: 4) titleLabel.text = "实时翻译" titleLabel.font = .boldSystemFont(ofSize: 22) - titleLabel.textColor = .black + titleLabel.textColor = UIColor(white: 1.0, alpha: 235.0 / 255.0) titleLabel.textAlignment = .center titleLabel.numberOfLines = 1 + titleLabel.isHidden = true - langLabel.text = "中文(简体) -> 英语" - langLabel.font = .systemFont(ofSize: 14) - langLabel.textColor = .darkGray - langLabel.textAlignment = .left + langLabel.text = "src -> tgt" + langLabel.font = .systemFont(ofSize: 12) + langLabel.textColor = UIColor(white: 1.0, alpha: 235.0 / 255.0) + langLabel.textAlignment = .center langLabel.numberOfLines = 1 + langLabel.lineBreakMode = .byTruncatingTail + langLabel.translatesAutoresizingMaskIntoConstraints = false + langLabel.setContentHuggingPriority(.required, for: .horizontal) + langLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + langContainer.backgroundColor = UIColor(white: 1.0, alpha: 36.0 / 255.0) + langContainer.layer.cornerRadius = 10 + langContainer.translatesAutoresizingMaskIntoConstraints = false + langContainer.setContentHuggingPriority(.required, for: .horizontal) + langContainer.setContentCompressionResistancePriority(.required, for: .horizontal) + langContainer.addSubview(langLabel) + NSLayoutConstraint.activate([ + langLabel.topAnchor.constraint(equalTo: langContainer.topAnchor, constant: 6), + langLabel.leadingAnchor.constraint(equalTo: langContainer.leadingAnchor, constant: 10), + langLabel.trailingAnchor.constraint(equalTo: langContainer.trailingAnchor, constant: -10), + langLabel.bottomAnchor.constraint(equalTo: langContainer.bottomAnchor, constant: -6), + ]) - cnLabel.font = .systemFont(ofSize: 18) - cnLabel.textColor = .black + cnLabel.font = .systemFont(ofSize: 14) + cnLabel.textColor = UIColor(white: 1.0, alpha: 235.0 / 255.0) cnLabel.textAlignment = .left cnLabel.numberOfLines = 0 + cnLabel.lineBreakMode = .byTruncatingTail - enLabel.font = .systemFont(ofSize: 18) - enLabel.textColor = UIColor(red: 0.24, green: 0.54, blue: 0.90, alpha: 1.0) + enLabel.font = .systemFont(ofSize: 15) + enLabel.textColor = UIColor(red: 144.0 / 255.0, green: 202.0 / 255.0, blue: 249.0 / 255.0, alpha: 1.0) enLabel.textAlignment = .left enLabel.numberOfLines = 0 + enLabel.lineBreakMode = .byTruncatingTail let para = NSMutableParagraphStyle() - para.lineSpacing = 4 - cnLabel.attributedText = NSAttributedString(string: "正在识别...", attributes: [.paragraphStyle: para]) - enLabel.attributedText = NSAttributedString(string: "翻译中...", attributes: [.paragraphStyle: para]) + para.lineSpacing = 2 + para.lineHeightMultiple = 1.1 + cnLabel.attributedText = NSAttributedString(string: "", attributes: [.paragraphStyle: para]) + enLabel.attributedText = NSAttributedString(string: "", attributes: [.paragraphStyle: para]) stackView.axis = .vertical - stackView.spacing = 12 + stackView.spacing = 10 stackView.alignment = .fill stackView.distribution = .fill stackView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(titleLabel) - stackView.addArrangedSubview(langLabel) + langRow.axis = .horizontal + langRow.alignment = .fill + langRow.distribution = .fill + langRow.translatesAutoresizingMaskIntoConstraints = false + let leftSpacer = UIView() + let rightSpacer = UIView() + leftSpacer.translatesAutoresizingMaskIntoConstraints = false + rightSpacer.translatesAutoresizingMaskIntoConstraints = false + langRow.addArrangedSubview(leftSpacer) + langRow.addArrangedSubview(langContainer) + langRow.addArrangedSubview(rightSpacer) + NSLayoutConstraint.activate([ + leftSpacer.widthAnchor.constraint(equalTo: rightSpacer.widthAnchor), + ]) + stackView.addArrangedSubview(langRow) stackView.addArrangedSubview(cnLabel) stackView.addArrangedSubview(enLabel) @@ -86,13 +133,61 @@ public class PlayerView: UIView { langLabel.text = lang } let para = NSMutableParagraphStyle() - para.lineSpacing = 4 + para.lineSpacing = 2 + para.lineHeightMultiple = 1.1 if let cn = cn { cnLabel.attributedText = NSAttributedString(string: cn, attributes: [.paragraphStyle: para]) } if let en = en { enLabel.attributedText = NSAttributedString(string: en, attributes: [.paragraphStyle: para]) } + setNeedsLayout() + } + + public override func layoutSubviews() { + super.layoutSubviews() + layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath + let currentLangText = langLabel.text + if bounds.size != lastBoundsSize || currentLangText != lastLangText { + lastBoundsSize = bounds.size + lastLangText = currentLangText + updateDynamicTextLineLimit() + } } -} + private func updateDynamicTextLineLimit() { + let fittingWidth = max(0, bounds.width - layoutMargins.left - layoutMargins.right) + if fittingWidth <= 0 { return } + + let visibleArrangedSubviews = stackView.arrangedSubviews.filter { !$0.isHidden } + let fixedSubviews = visibleArrangedSubviews.filter { $0 !== cnLabel && $0 !== enLabel } + let fixedHeight = fixedSubviews.reduce(CGFloat(0)) { partialResult, view in + partialResult + + view.systemLayoutSizeFitting( + CGSize(width: fittingWidth, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + } + let totalSpacing = CGFloat(max(0, visibleArrangedSubviews.count - 1)) * stackView.spacing + + let availableHeight = + bounds.height + - layoutMargins.top + - layoutMargins.bottom + - fixedHeight + - totalSpacing + + let cnLineHeight = cnLabel.font.lineHeight * 1.1 + 2 + let enLineHeight = enLabel.font.lineHeight * 1.1 + 2 + let combinedLineHeight = cnLineHeight + enLineHeight + + let perLabelLines = max(1, min(Int(availableHeight / combinedLineHeight), 6)) + + if perLabelLines != lastDynamicTextLines { + lastDynamicTextLines = perLabelLines + cnLabel.numberOfLines = perLabelLines + enLabel.numberOfLines = perLabelLines + } + } +} From afa4db9099ee0bee2a0630b4ab2b8504f358e3d2 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 15 Jan 2026 20:57:13 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat(=E6=92=AD=E6=94=BE=E5=99=A8):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=AF=E6=89=A9=E5=B1=95=E7=9A=84=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E8=A7=86=E5=9B=BE=E5=92=8C=E6=82=AC=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现播放器视图的扩展/折叠功能,根据内容自动调整高度 为iOS悬浮窗添加类似的可扩展功能,优化用户体验 --- .../controllers/translation_controller.dart | 43 ++++++++++++++----- .../ios/Classes/PlayerView.swift | 31 ++++++++++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 56d64f1d2..994d1c109 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -61,6 +61,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { late final Pip _pip; late final NativePlugin _nativePlugin; int _pipContentView = 0; + static const int _pipPreferredWidth = 375; + static const int _pipCompactPreferredHeight = 72; + static const int _pipExpandedPreferredHeight = 220; + bool _isPipExpanded = false; // ==================== 悬浮窗相关 ==================== final isFloatingWindowEnabled = false.obs; // 悬浮窗状态 @@ -174,7 +178,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { isFloatingWindowEnabled.value = state == PipState.pipStateStarted; if (state == PipState.pipStateFailed) { + _isPipExpanded = false; _pip.dispose(); + } else if (state == PipState.pipStateStopped) { + _isPipExpanded = false; } }, ), @@ -190,6 +197,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (Platform.isAndroid) { await _disableFloatingWindow(); } else if (Platform.isIOS) { + _isPipExpanded = false; await _pip.dispose(); } } else { @@ -201,14 +209,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } - Future _startPip() async { - if (_pipContentView == 0) { - _pipContentView = await _nativePlugin.createPipContentView(); - } - final options = PipOptions( + PipOptions _buildIosPipOptions({required int preferredHeight}) { + return PipOptions( autoEnterEnabled: true, - aspectRatioX: 375, - aspectRatioY: 400, + aspectRatioX: _pipPreferredWidth, + aspectRatioY: preferredHeight, sourceRectHintLeft: 0, sourceRectHintTop: 0, sourceRectHintRight: 0, @@ -217,12 +222,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver { useExternalStateMonitor: true, externalStateMonitorInterval: 100, contentView: _pipContentView, - preferredContentWidth: 375, - preferredContentHeight: 400, + preferredContentWidth: _pipPreferredWidth, + preferredContentHeight: preferredHeight, controlStyle: 2, ); + } - await _pip.setup(options); + Future _startPip() async { + if (_pipContentView == 0) { + _pipContentView = await _nativePlugin.createPipContentView(); + } + _isPipExpanded = false; + await _pip.setup( + _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); await _pip.start(); } @@ -297,6 +309,17 @@ class TranslationController extends GetxController with WidgetsBindingObserver { ); await _floatingWindowManager.updateFloatingWindowContent(data); } else if (Platform.isIOS) { + final hasContent = item.sourceText.trim().isNotEmpty || + item.translatedText.trim().isNotEmpty; + if (hasContent && !_isPipExpanded) { + final ok = await _pip.setup( + _buildIosPipOptions(preferredHeight: _pipExpandedPreferredHeight)); + if (ok) _isPipExpanded = true; + } else if (!hasContent && _isPipExpanded) { + final ok = await _pip.setup( + _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); + if (ok) _isPipExpanded = false; + } await _nativePlugin.updatePipContentView({ 'viewId': _pipContentView, 'cn': item.sourceText, diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.swift b/local_plugins/native_plugin/ios/Classes/PlayerView.swift index 98e624768..a0d690191 100644 --- a/local_plugins/native_plugin/ios/Classes/PlayerView.swift +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.swift @@ -11,6 +11,9 @@ public class PlayerView: UIView { private let langRow: UIStackView = UIStackView() private var lastBoundsSize: CGSize = .zero private var lastLangText: String? = nil + private var isExpanded: Bool = false + private let compactPreferredHeight: CGFloat = 72 + private let expandedPreferredHeight: CGFloat = 220 public override init(frame: CGRect) { stackView = UIStackView() @@ -73,12 +76,14 @@ public class PlayerView: UIView { cnLabel.textAlignment = .left cnLabel.numberOfLines = 0 cnLabel.lineBreakMode = .byTruncatingTail + cnLabel.isHidden = true enLabel.font = .systemFont(ofSize: 15) enLabel.textColor = UIColor(red: 144.0 / 255.0, green: 202.0 / 255.0, blue: 249.0 / 255.0, alpha: 1.0) enLabel.textAlignment = .left enLabel.numberOfLines = 0 enLabel.lineBreakMode = .byTruncatingTail + enLabel.isHidden = true let para = NSMutableParagraphStyle() para.lineSpacing = 2 @@ -110,7 +115,7 @@ public class PlayerView: UIView { stackView.addArrangedSubview(cnLabel) stackView.addArrangedSubview(enLabel) - layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + layoutMargins = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16) addSubview(stackView) let guide = layoutMarginsGuide @@ -141,11 +146,22 @@ public class PlayerView: UIView { if let en = en { enLabel.attributedText = NSAttributedString(string: en, attributes: [.paragraphStyle: para]) } + let currentCn = cnLabel.attributedText?.string ?? "" + let currentEn = enLabel.attributedText?.string ?? "" + let hasContent = + !currentCn.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !currentEn.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + setExpanded(hasContent) setNeedsLayout() } + public override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: isExpanded ? expandedPreferredHeight : compactPreferredHeight) + } + public override func layoutSubviews() { super.layoutSubviews() + layer.cornerRadius = isExpanded ? 16 : 18 layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath let currentLangText = langLabel.text if bounds.size != lastBoundsSize || currentLangText != lastLangText { @@ -155,6 +171,17 @@ public class PlayerView: UIView { } } + private func setExpanded(_ expanded: Bool) { + if expanded == isExpanded { return } + isExpanded = expanded + cnLabel.isHidden = !expanded + enLabel.isHidden = !expanded + layoutMargins = expanded + ? UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + : UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16) + invalidateIntrinsicContentSize() + } + private func updateDynamicTextLineLimit() { let fittingWidth = max(0, bounds.width - layoutMargins.left - layoutMargins.right) if fittingWidth <= 0 { return } @@ -182,7 +209,7 @@ public class PlayerView: UIView { let enLineHeight = enLabel.font.lineHeight * 1.1 + 2 let combinedLineHeight = cnLineHeight + enLineHeight - let perLabelLines = max(1, min(Int(availableHeight / combinedLineHeight), 6)) + let perLabelLines = max(1, min(Int(availableHeight / combinedLineHeight), 10)) if perLabelLines != lastDynamicTextLines { lastDynamicTextLines = perLabelLines From 9d19ce292a03b8ca53f5d6427fd86f5bd77b3f95 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 15 Jan 2026 21:14:09 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat(=E4=BC=9A=E8=AE=AE=E8=AE=B0=E5=BD=95?= =?UTF-8?q?):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=B9=B6=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在保存会议记录后添加_notifyMeetingLocalImport通知 修改返回逻辑,添加返回结果并删除控制器 --- lib/modules/meeting/views/meeting_record_view.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index f2bf8f9d3..eca269957 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -708,6 +708,7 @@ class MeetingRecordView extends GetView { await controller.saveTitleChange(); final saved = await controller.saveRecording(); if (saved) { + _notifyMeetingLocalImport(); Get.back(); } }, @@ -743,8 +744,10 @@ class MeetingRecordView extends GetView { await controller.saveTitleChange(); final saved = await controller.saveRecording(); if (saved) { + _notifyMeetingLocalImport(); Get.back(); //关闭弹窗 - Get.back(); //回退到会议记录页面 + Get.back(result: 'meeting_record_saved'); //回退到会议记录页面 + _deleteControllerAfterPop(); } }, style: ElevatedButton.styleFrom( From 040e06cab677f2d1f0d91eb6a8be3f44c62d53a3 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 16 Jan 2026 09:40:58 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix(android):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6=E5=88=86=E4=BA=AB=E5=92=8C?= =?UTF-8?q?=E6=B7=B1=E9=93=BE=E6=8E=A5=E5=A4=84=E7=90=86=E7=9A=84=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加重试机制和错误处理,确保音频文件和深链接在Flutter引擎未就绪时能够正确传递 修复文件处理中的潜在空指针异常,并优化文件复制逻辑 --- .../com/saitong/voitrans/MainActivity.kt | 218 +++++++++++++----- lib/main.dart | 7 + 2 files changed, 171 insertions(+), 54 deletions(-) diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt b/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt index e11ffb7eb..acf5653b8 100644 --- a/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt +++ b/android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt @@ -25,10 +25,15 @@ class MainActivity: FlutterActivity() { private var pendingDeepLink: Intent? = null private var pendingNavigationIntent: Intent? = null private var pendingShareIntent: Intent? = null + private var pendingSharedFilePath: String? = null + private var pendingDeepLinkUrl: String? = null private val EXTRA_NAVIGATE_TO = "com.saitong.voitrans.extra.NAVIGATE_TO" private val NAVIGATE_TO_MEETING_RECORD = "meetingRecord" private val ROUTE_MEETING_RECORD = "/meeting/record" + private val DEEPLINK_CHANNEL = "com.saitong.voitrans/deeplink" + private val DELIVERY_RETRY_DELAY_MS = 300L + private val MAX_DELIVERY_RETRIES = 12 override fun onCreate(savedInstanceState: Bundle?) { Log.d(TAG, "🚀 MainActivity onCreate 开始") @@ -78,8 +83,28 @@ class MainActivity: FlutterActivity() { Log.d(TAG, "New Intent data: ${intent.data}") Log.d(TAG, "New Intent extras: ${intent.extras}") - // 处理深链接 - handleNavigationIntent(intent) + intent.let { + if (it.action == Intent.ACTION_SEND) { + pendingShareIntent = it + Log.d(TAG, "📎 暂存分享文件意图,等待Flutter引擎准备完成") + } else if (it.action == Intent.ACTION_VIEW && it.data != null) { + val uri = it.data + if (uri != null && isLikelyAudio(it, uri)) { + pendingShareIntent = it + Log.d(TAG, "📎 暂存打开文件意图,等待Flutter引擎准备完成") + } else { + pendingDeepLink = it + Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成") + } + } + } + + if (intent.hasExtra(EXTRA_NAVIGATE_TO)) { + pendingNavigationIntent = intent + Log.d(TAG, "🧭 暂存导航意图,等待Flutter引擎准备完成") + } + + deliverOrQueueIntent(intent) } override fun provideFlutterEngine(context: Context): FlutterEngine? { @@ -127,19 +152,19 @@ class MainActivity: FlutterActivity() { Handler(Looper.getMainLooper()).postDelayed({ pendingShareIntent?.let { intent -> Log.d(TAG, "📎 处理待处理的分享/打开文件意图") - handleNavigationIntent(intent) + deliverOrQueueIntent(intent) pendingShareIntent = null } pendingDeepLink?.let { intent -> Log.d(TAG, "🔗 处理待处理的深链接") - handleNavigationIntent(intent) + deliverOrQueueIntent(intent) pendingDeepLink = null } pendingNavigationIntent?.let { intent -> Log.d(TAG, "🧭 处理待处理的导航意图") - handleNavigationIntent(intent) + deliverOrQueueIntent(intent) pendingNavigationIntent = null } }, 1000) // 延迟1秒确保Flutter完全准备好 @@ -147,67 +172,143 @@ class MainActivity: FlutterActivity() { Log.d(TAG, "✅ configureFlutterEngine 完成") } - // 处理深链接的统一方法 - private fun handleNavigationIntent(intent: Intent?) { - intent?.let { - val navigateTo = it.getStringExtra(EXTRA_NAVIGATE_TO) - if (navigateTo == NAVIGATE_TO_MEETING_RECORD) { - pushRouteToFlutter(ROUTE_MEETING_RECORD) - return - } + private fun deliverOrQueueIntent(intent: Intent?) { + intent ?: return - if (it.action == Intent.ACTION_SEND || it.action == Intent.ACTION_VIEW) { - try { - val path = extractAudioPathFromIntent(it) - if (path != null) { - flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> - val channel = MethodChannel(messenger, "com.saitong.voitrans/deeplink") - channel.invokeMethod("handleSharedFile", mapOf("filePath" to path)) - return - } - } - } catch (e: Exception) { - Log.e(TAG, "❌ 处理分享/打开文件失败", e) + val navigateTo = intent.getStringExtra(EXTRA_NAVIGATE_TO) + if (navigateTo == NAVIGATE_TO_MEETING_RECORD) { + pushRouteToFlutter(ROUTE_MEETING_RECORD) + return + } + + if (intent.action == Intent.ACTION_SEND || + intent.action == Intent.ACTION_SEND_MULTIPLE || + intent.action == Intent.ACTION_VIEW + ) { + try { + val path = extractAudioPathFromIntent(intent) + if (path != null) { + pendingSharedFilePath = path + deliverPendingSharedFile() + return } + } catch (e: Exception) { + Log.e(TAG, "❌ 处理分享/打开文件失败", e) } + } - if (it.action == Intent.ACTION_VIEW && it.data != null) { - val uri = it.data - Log.d(TAG, "🔗 处理深链接: $uri") - - try { - Log.d(TAG, "🔗 开始发送深链接到 Flutter") - - // 通过 MethodChannel 将深链接信息传递给 Flutter - flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> - Log.d(TAG, "🔗 获取到 BinaryMessenger") - - val channel = MethodChannel( - messenger, - "com.saitong.voitrans/deeplink" + if (intent.action == Intent.ACTION_VIEW && intent.data != null) { + val uri = intent.data + Log.d(TAG, "🔗 处理深链接: $uri") + pendingDeepLinkUrl = uri.toString() + deliverPendingDeepLink() + return + } + + Log.d(TAG, "🔗 Intent 不是深链接或数据为空") + } + + private fun getEngineForMessaging(): FlutterEngine? { + return flutterEngine ?: FlutterEngineCache.getInstance().get("main_engine") + } + + private fun deliverPendingSharedFile(attempt: Int = 0) { + val filePath = pendingSharedFilePath ?: return + val engine = getEngineForMessaging() + val messenger = engine?.dartExecutor?.binaryMessenger + if (messenger == null) { + if (attempt < MAX_DELIVERY_RETRIES) { + Handler(Looper.getMainLooper()).postDelayed( + { deliverPendingSharedFile(attempt + 1) }, + DELIVERY_RETRY_DELAY_MS + ) + } else { + Log.w(TAG, "❌ FlutterEngine 未就绪,放弃发送分享文件:$filePath") + } + return + } + + val channel = MethodChannel(messenger, DEEPLINK_CHANNEL) + channel.invokeMethod( + "handleSharedFile", + mapOf("filePath" to filePath), + object : MethodChannel.Result { + override fun success(result: Any?) { + pendingSharedFilePath = null + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + Log.e(TAG, "❌ Flutter 处理分享文件返回错误:$errorCode, $errorMessage") + } + + override fun notImplemented() { + if (attempt < MAX_DELIVERY_RETRIES) { + Handler(Looper.getMainLooper()).postDelayed( + { deliverPendingSharedFile(attempt + 1) }, + DELIVERY_RETRY_DELAY_MS ) - - val arguments = buildUrlArguments(uri.toString()) - - Log.d(TAG, "🔗 准备发送参数: $arguments") - - channel.invokeMethod("handleDeepLink", arguments) - Log.d(TAG, "✅ 深链接信息已发送到 Flutter") - } ?: run { - Log.w(TAG, "❌ BinaryMessenger 为空,无法发送深链接") + } else { + Log.w(TAG, "❌ Flutter 未注册分享处理器,放弃发送分享文件:$filePath") } - } catch (e: Exception) { - Log.e(TAG, "❌ 处理深链接时出错", e) } + } + ) + } + + private fun deliverPendingDeepLink(attempt: Int = 0) { + val url = pendingDeepLinkUrl ?: return + val engine = getEngineForMessaging() + val messenger = engine?.dartExecutor?.binaryMessenger + if (messenger == null) { + if (attempt < MAX_DELIVERY_RETRIES) { + Handler(Looper.getMainLooper()).postDelayed( + { deliverPendingDeepLink(attempt + 1) }, + DELIVERY_RETRY_DELAY_MS + ) } else { - Log.d(TAG, "🔗 Intent 不是深链接或数据为空") + Log.w(TAG, "❌ FlutterEngine 未就绪,放弃发送深链接:$url") } + return } + + val channel = MethodChannel(messenger, DEEPLINK_CHANNEL) + val arguments = buildUrlArguments(url) + channel.invokeMethod( + "handleDeepLink", + arguments, + object : MethodChannel.Result { + override fun success(result: Any?) { + pendingDeepLinkUrl = null + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + Log.e(TAG, "❌ Flutter 处理深链接返回错误:$errorCode, $errorMessage") + } + + override fun notImplemented() { + if (attempt < MAX_DELIVERY_RETRIES) { + Handler(Looper.getMainLooper()).postDelayed( + { deliverPendingDeepLink(attempt + 1) }, + DELIVERY_RETRY_DELAY_MS + ) + } else { + Log.w(TAG, "❌ Flutter 未注册深链接处理器,放弃发送深链接:$url") + } + } + } + ) } private fun extractAudioPathFromIntent(intent: Intent): String? { val uri: Uri? = when (intent.action) { - Intent.ACTION_SEND -> intent.getParcelableExtra(Intent.EXTRA_STREAM) + Intent.ACTION_SEND -> { + val extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM) + extraStream ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri + } + Intent.ACTION_SEND_MULTIPLE -> { + val items = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + items?.firstOrNull() ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri + } Intent.ACTION_VIEW -> intent.data else -> null } @@ -246,17 +347,26 @@ class MainActivity: FlutterActivity() { val sharedDir = File(applicationContext.filesDir, "shared_audio") if (!sharedDir.exists()) sharedDir.mkdirs() val outFile = File(sharedDir, "audio_${System.currentTimeMillis()}.$ext") - resolver.openInputStream(uri)?.use { inStream -> + val inStream = resolver.openInputStream(uri) + if (inStream == null) { + outFile.delete() + return null + } + inStream.use { FileOutputStream(outFile).use { out -> val buffer = ByteArray(8192) var len: Int while (true) { - len = inStream.read(buffer) + len = it.read(buffer) if (len <= 0) break out.write(buffer, 0, len) } } } + if (!outFile.exists() || outFile.length() <= 0) { + outFile.delete() + return null + } outFile.absolutePath } catch (e: Exception) { Log.e(TAG, "复制分享文件到缓存失败: ${e.message}", e) diff --git a/lib/main.dart b/lib/main.dart index ac646c1fb..cf1d1b228 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'core/themes/theme_service.dart'; import 'core/bindings/initial_binding.dart'; import 'data/services/login_service.dart'; import 'dart:ui' as ui; +import 'modules/meeting/controllers/meeting_home_controller.dart'; import 'modules/splash/controllers/splash_controller.dart'; bool _deepLinkHandlerInitialized = false; @@ -133,6 +134,12 @@ Future _handleSharedAudioFile(String filePath) async { const maxRetries = 10; while (retryCount < maxRetries) { try { + if (Get.isRegistered()) { + final controller = Get.find(); + controller.addAudio(filePath); + return; + } + Get.toNamed( Routes.meeting, arguments: { From bf70737bc7945206f18dbcb3ee354ea7a24c5d61 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Fri, 16 Jan 2026 10:03:54 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BC=9A=E8=AE=AE?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=EF=BC=8C=E4=BD=BF=E7=94=A8=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E8=BD=AC=E5=86=99=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controllers/meeting_home_controller.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index bbe666138..aa8806c5f 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -277,6 +277,7 @@ class MeetingHomeController extends GetxController { // 插入数据库 final response = await Api.addRecordEchomeet({ 'rtype': 'LOCAL', + 'size': fileStat.size, ...data, }); final meetingId = response['record']['id']; @@ -408,6 +409,7 @@ class MeetingHomeController extends GetxController { final response = await Api.addRecordEchomeet({ 'rtype': recordType, + 'size': fileStat.size, ...data, }); final meetingId = response['record']['id']; @@ -521,6 +523,7 @@ class MeetingHomeController extends GetxController { await _audioPlayer.setAudioSource(AudioSource.file(filePath)); int seconds = _audioPlayer.duration?.inSeconds ?? 0; int creationTime = DateTime.now().millisecondsSinceEpoch; + final fileStat = await File(filePath).stat(); Map data = { 'title': fileName, 'type': type, // 使用传入的type参数 @@ -530,7 +533,11 @@ class MeetingHomeController extends GetxController { 'tasktype': 0, 'creationtime': creationTime, }; - final response = await Api.addRecordEchomeet({'rtype': type, ...data}); + final response = await Api.addRecordEchomeet({ + 'rtype': type, + 'size': fileStat.size, + ...data, + }); final meetingId = response['record']['id']; final meetingResult = await SqfliteApi.insertMeeting({ 'id': meetingId, From 0e759ca94a0f91a79b81db8308492ec17c6729e0 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 16 Jan 2026 15:34:30 +0800 Subject: [PATCH 10/15] =?UTF-8?q?feat(=E4=BC=9A=E8=AE=AE=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?):=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AE=AE=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E6=89=A3=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在创建会议任务时,根据会议音频时长扣除用户积分 --- .../meeting/meeting_task_service.dart | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/data/services/meeting/meeting_task_service.dart b/lib/data/services/meeting/meeting_task_service.dart index b3a080d63..1960749a5 100644 --- a/lib/data/services/meeting/meeting_task_service.dart +++ b/lib/data/services/meeting/meeting_task_service.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import '../db/sqflite_api.dart'; import '../network/api.dart'; +import '../../models/user_Info.dart'; import 'meeting_upload_service.dart'; class MeetingTaskService extends GetxService { @@ -81,6 +83,8 @@ class MeetingTaskService extends GetxService { 'isdistinguishspeaker': isdistinguishspeaker, 'templateid': templateid, }); + final seconds = await _getMeetingSeconds(id); + _decrementMeetingIntegral(cost: seconds); _taskList.add({ 'id': id, 'tasktype': 1, @@ -207,4 +211,27 @@ class MeetingTaskService extends GetxService { } } } + + /// 获取会议音频秒数 + Future _getMeetingSeconds(int id) async { + try { + final data = await SqfliteApi.getMeetingDetails(id); + final v = data['seconds']; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; + } catch (_) { + return 0; + } + } + + /// 减少用户会议积分 + void _decrementMeetingIntegral({required int cost}) { + if (!User.isLoggedIn()) return; + if (cost <= 0) return; + final next = User.instance.meetintegral - cost; + User.instance.updateUserInfo({'meetintegral': next < 0 ? 0 : next}); + //GetStorage().write('user_info', User.instance.toJson()); + } } From ddcc283c500fc058b8a60385d88347bd0b5122a8 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 16 Jan 2026 17:05:56 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix(=E4=B8=8A=E4=BC=A0):=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=9A=84=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E5=92=8CWAV=E6=96=87=E4=BB=B6=E5=A4=B4?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E8=A7=A3=E5=86=B3=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E5=90=8E=E5=8D=B3=E5=88=BB=E8=BD=AC=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E5=AE=8C=E6=95=B4=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E6=96=87=E4=BB=B6=E5=AE=8C=E5=85=A8=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E5=90=8E=E6=89=8D=E4=B8=8A=E4=BC=A0cos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加文件大小稳定检测机制,防止上传不完整文件 增加WAV文件头校验,确保文件完整性 优化文件移动操作,优先使用rename提高效率 --- .../meeting/meeting_upload_service.dart | 105 +++++++++++++++++- .../controllers/meeting_home_controller.dart | 22 +++- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/lib/data/services/meeting/meeting_upload_service.dart b/lib/data/services/meeting/meeting_upload_service.dart index af54033a2..2f8329cdb 100644 --- a/lib/data/services/meeting/meeting_upload_service.dart +++ b/lib/data/services/meeting/meeting_upload_service.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:async'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -21,6 +22,9 @@ class MeetingUploadService extends GetxService { bool _isUpload = false; int _handled = 0; //队列处理指针,表示当前循环语句处理的任务数量 + static const Duration _fileStableTimeout = Duration(seconds: 20); + static const Duration _fileStablePoll = Duration(milliseconds: 350); + static const int _fileStableNeededCount = 3; @override void onInit() { @@ -80,8 +84,26 @@ class MeetingUploadService extends GetxService { meetingId.value = element['id']; try { int totalProgress = 0; + final filePath = (element['filepath'] ?? '').toString(); + if (filePath.isEmpty) { + throw Exception('Empty upload file path'); + } + final fileSizeBytes = await _waitForStableFileSize( + filePath, + timeout: _fileStableTimeout, + pollInterval: _fileStablePoll, + stableNeededCount: _fileStableNeededCount, + ); + final isWav = filePath.toLowerCase().endsWith('.wav'); + if (isWav) { + final ok = await _isWavHeaderConsistent(filePath, fileSizeBytes); + if (!ok) { + throw Exception('WAV header is inconsistent with file size'); + } + } + element['fileSizeBytes'] = fileSizeBytes; final audiourl = await UploadOss.upload( - filepath: element['filepath'], + filepath: filePath, rootDir: element['rootdir'], onSendProgress: (int count, int total) { // uploadProgress.value = count / total; @@ -139,6 +161,87 @@ class MeetingUploadService extends GetxService { _isUpload = false; } + Future _waitForStableFileSize( + String filePath, { + Duration timeout = _fileStableTimeout, + Duration pollInterval = _fileStablePoll, + int stableNeededCount = _fileStableNeededCount, + }) async { + final file = File(filePath); + final deadline = DateTime.now().add(timeout); + int lastSize = -1; + int stableCount = 0; + while (DateTime.now().isBefore(deadline)) { + final exists = await file.exists(); + if (exists) { + int size = 0; + try { + size = await file.length(); + } catch (_) { + size = 0; + } + if (size > 0) { + if (size == lastSize) { + stableCount += 1; + } else { + stableCount = 0; + lastSize = size; + } + if (stableCount >= stableNeededCount) { + return size; + } + } + } + await Future.delayed(pollInterval); + } + throw TimeoutException('File not stable: $filePath', timeout); + } + + Future _isWavHeaderConsistent( + String filePath, int fileSizeBytes) async { + RandomAccessFile? raf; + try { + raf = await File(filePath).open(mode: FileMode.read); + final header = await raf.read(12); + if (header.length < 12) return false; + final riff = String.fromCharCodes(header.sublist(0, 4)); + final wave = String.fromCharCodes(header.sublist(8, 12)); + if (riff != 'RIFF' || wave != 'WAVE') return true; + + int offset = 12; + const int maxScanBytes = 64 * 1024; + while (offset + 8 <= fileSizeBytes && offset <= maxScanBytes) { + await raf.setPosition(offset); + final chunkHeader = await raf.read(8); + if (chunkHeader.length < 8) return false; + final chunkId = String.fromCharCodes(chunkHeader.sublist(0, 4)); + final chunkSize = _readUint32Le(chunkHeader, 4); + final chunkDataStart = offset + 8; + final expectedEnd = chunkDataStart + chunkSize; + if (chunkId == 'data') { + return fileSizeBytes >= expectedEnd; + } + final paddedSize = chunkSize + (chunkSize % 2); + offset = chunkDataStart + paddedSize; + } + return true; + } catch (_) { + return true; + } finally { + try { + await raf?.close(); + } catch (_) {} + } + } + + int _readUint32Le(List bytes, int offset) { + if (offset + 4 > bytes.length) return 0; + return (bytes[offset]) | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24); + } + void removeUpload(int id, {bool isDeleteTask = false}) { Map uploadData = uploadList.firstWhere( (element) => element['id'] == id, diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index aa8806c5f..0d988cd88 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -260,8 +260,15 @@ class MeetingHomeController extends GetxController { final creationTime = fileStat.changed.millisecondsSinceEpoch; // 准备新路径 - final fileName = filePath.split('/').last; //获取文件名(包含扩展名) - final newPath = "${destDir.path}/$fileName"; //构建新路径,保持原文件名 + String fileName = filePath.split('/').last; //获取文件名(包含扩展名) + String newPath = "${destDir.path}/$fileName"; //构建新路径,保持原文件名 + if (await File(newPath).exists()) { + final ext = path.extension(fileName); + final base = path.basenameWithoutExtension(fileName); + final ts = DateTime.now().millisecondsSinceEpoch; + fileName = '${base}_$ts$ext'; + newPath = "${destDir.path}/$fileName"; + } // 构建要存入数据库的音频文件抽象对象 Map data = { @@ -286,6 +293,13 @@ class MeetingHomeController extends GetxController { ...data, }); if (meetingResult != null) { + // 移动文件:优先 rename,失败再 copy+delete,确保上传读取的是完整目标文件 + try { + await file.rename(newPath); + } catch (_) { + await file.copy(newPath); + await file.delete(); + } // 安排后台上传任务 meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio'); @@ -306,10 +320,6 @@ class MeetingHomeController extends GetxController { }); } - // 移动文件 - await file.copy(newPath); // 复制到新路径 - await file.delete(); // 删除原文件 - addUp++; } } catch (e) { From 231bc66f5f7bd07a89640782e3cb705a42a4b05b Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 16 Jan 2026 21:31:15 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat(floating=5Fui):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=82=AC=E6=B5=AEUI=E6=8F=92=E4=BB=B6=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E6=82=AC=E6=B5=AE=E7=AA=97=E5=92=8C=E5=BD=95?= =?UTF-8?q?=E9=9F=B3=E6=82=AC=E6=B5=AE=E6=9D=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加Android系统悬浮窗服务实现翻译悬浮窗功能 - 实现录音悬浮条服务支持显示/更新/隐藏操作 - 新增iOS画中画(PiP)内容视图支持动态更新翻译文本 - 完善权限检查和事件回传机制 - 更新README详细说明插件功能和使用方式 --- .../.gitignore | 0 .../.metadata | 0 .../CHANGELOG.md | 0 .../LICENSE | 0 local_plugins/floating_ui_plugin/README.md | 120 ++++++++++++++++++ .../analysis_options.yaml | 0 .../android/.gitignore | 0 .../android/build.gradle | 0 .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 0 .../native_plugin/FloatingWindowService.kt | 0 .../com/example/native_plugin/NativePlugin.kt | 0 .../native_plugin/RecordingFloatingService.kt | 0 .../native_plugin/NativePluginTest.java | 0 .../ios/.gitignore | 0 .../ios/Assets/.gitkeep | 0 .../ios/Classes/FLNativeView.swift | 0 .../ios/Classes/NativePlugin.swift | 0 .../ios/Classes/PlayerView.swift | 0 .../ios/Resources/PrivacyInfo.xcprivacy | 0 .../ios/floating_ui_plugin.podspec | 0 .../lib/native_plugin.dart | 0 .../lib/native_plugin_method_channel.dart | 0 .../lib/native_plugin_platform_interface.dart | 0 .../pubspec.yaml | 0 local_plugins/native_plugin/README.md | 15 --- .../native_plugin/android/settings.gradle | 1 - pubspec.yaml | 2 +- 28 files changed, 122 insertions(+), 17 deletions(-) rename local_plugins/{native_plugin => floating_ui_plugin}/.gitignore (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/.metadata (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/CHANGELOG.md (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/LICENSE (100%) create mode 100644 local_plugins/floating_ui_plugin/README.md rename local_plugins/{native_plugin => floating_ui_plugin}/analysis_options.yaml (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/.gitignore (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/build.gradle (100%) create mode 100644 local_plugins/floating_ui_plugin/android/settings.gradle rename local_plugins/{native_plugin => floating_ui_plugin}/android/src/main/AndroidManifest.xml (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/android/src/test/java/com/example/native_plugin/NativePluginTest.java (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/.gitignore (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/Assets/.gitkeep (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/Classes/FLNativeView.swift (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/Classes/NativePlugin.swift (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/Classes/PlayerView.swift (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/Resources/PrivacyInfo.xcprivacy (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/ios/floating_ui_plugin.podspec (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/lib/native_plugin.dart (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/lib/native_plugin_method_channel.dart (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/lib/native_plugin_platform_interface.dart (100%) rename local_plugins/{native_plugin => floating_ui_plugin}/pubspec.yaml (100%) delete mode 100644 local_plugins/native_plugin/README.md delete mode 100644 local_plugins/native_plugin/android/settings.gradle diff --git a/local_plugins/native_plugin/.gitignore b/local_plugins/floating_ui_plugin/.gitignore similarity index 100% rename from local_plugins/native_plugin/.gitignore rename to local_plugins/floating_ui_plugin/.gitignore diff --git a/local_plugins/native_plugin/.metadata b/local_plugins/floating_ui_plugin/.metadata similarity index 100% rename from local_plugins/native_plugin/.metadata rename to local_plugins/floating_ui_plugin/.metadata diff --git a/local_plugins/native_plugin/CHANGELOG.md b/local_plugins/floating_ui_plugin/CHANGELOG.md similarity index 100% rename from local_plugins/native_plugin/CHANGELOG.md rename to local_plugins/floating_ui_plugin/CHANGELOG.md diff --git a/local_plugins/native_plugin/LICENSE b/local_plugins/floating_ui_plugin/LICENSE similarity index 100% rename from local_plugins/native_plugin/LICENSE rename to local_plugins/floating_ui_plugin/LICENSE diff --git a/local_plugins/floating_ui_plugin/README.md b/local_plugins/floating_ui_plugin/README.md new file mode 100644 index 000000000..369c2ee35 --- /dev/null +++ b/local_plugins/floating_ui_plugin/README.md @@ -0,0 +1,120 @@ +# floating_ui_plugin + +该目录是一个本地 Flutter 插件包,当前在项目中主要承担「悬浮 UI」相关的原生能力: + +- Android:翻译悬浮窗(系统悬浮窗 / overlay window)+ 录音悬浮条(计时/暂停/关闭)+ 悬浮窗权限检查/跳转。 +- iOS:为系统画中画(PiP)提供可渲染的原生内容 View,并支持动态更新翻译文本。 + +> 说明:Flutter 包名为 floating_ui_plugin(见本插件 pubspec.yaml),主工程通过 path 依赖引用本目录。 + +## 主要能力 + +### 1) Android 翻译悬浮窗(Translation Floating Window) + +- 通道:`MethodChannel('floating_window')` +- 原理:Flutter 调用通道方法 → Android 启动/更新 `FloatingWindowService` → service 通过系统 WindowManager 显示 overlay view。 +- 交互回传:service 会反向调用同一通道,把状态/点击/关闭事件回传给 Flutter。 + +支持的方法(Flutter → Android): +- `enableFloatingWindow`:启动悬浮窗服务(如果已在运行,相当于确保存在)。 +- `disableFloatingWindow`:停止悬浮窗服务。 +- `showFloatingWindow`:显示悬浮窗(当前实现等价于启动服务)。 +- `hideFloatingWindow`:隐藏悬浮窗(stopSelf)。 +- `updateContent(Map)`:更新展示内容。 +- `setPosition({x,y})`:设置悬浮窗位置。 + +回传的方法(Android → Flutter): +- `onFloatingWindowStateChanged({state})`:`hidden|minimized|expanded` +- `onFloatingWindowClosed()`:用户关闭。 +- `onFloatingWindowClicked()`:用户点击语言区域(service 内部会 bringAppToFront)。 + +数据协议(updateContent): +- `sourceText` / `translatedText` +- `sourceLanguage` / `targetLanguage` +- `timestamp`(毫秒时间戳) +- `isIntermediate`(是否中间态) + +对应实现: +- Android 插件入口:[NativePlugin.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt) +- 悬浮窗服务:[FloatingWindowService.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt) + +### 2) Android 悬浮窗权限(Overlay Permission) + +- 通道:`MethodChannel('overlay_permission')` +- 方法: + - `hasOverlayPermission`:是否具备 SYSTEM_ALERT_WINDOW + - `requestOverlayPermission`:跳转系统页面申请 + +对应实现:[NativePlugin.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt) + +### 3) Android 录音悬浮条(Recording Floating Bar) + +- 通道:`MethodChannel('native_plugin')` +- 原理:Flutter 下发 show/update/hide → Android 启动/更新 `RecordingFloatingService` → service 以 overlay 方式显示一个小型悬浮条。 + +支持的方法(Flutter → Android): +- `showRecordingFloatingBar({duration, recordingType, isPaused})` +- `updateRecordingFloatingBar({duration, recordingType, isPaused})` +- `hideRecordingFloatingBar()` + +用户操作回传(Android → Flutter): +- 事件通道:`EventChannel('native_plugin/recording_floating_actions')` +- action 字符串: + - `togglePause`:点击暂停/继续 + - `openMeetingRecord`:点击中间区域,拉起 App 并打开录音页 + - `closeFloatingBar`:关闭悬浮条 + +对应实现: +- 服务:[RecordingFloatingService.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt) +- 事件桥接:[NativePlugin.emitRecordingFloatingAction](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt#L26-L39) + +## iOS:PiP 内容 View(Picture-in-Picture Content View) + +- 通道:`MethodChannel('native_plugin')` +- 目标:创建一个用于 PiP 渲染的原生 `UIView`(`PlayerView`),并支持后续更新显示内容。 + +支持的方法: +- `createPipContentView()`:返回一个 `viewId`(本质是 PlayerView 的指针值)。 +- `updatePipContentView({viewId?, lang, cn, en})`:更新内容。 +- `disposePipContentView(viewId)`:释放。 + +对应实现: +- 插件入口:[NativePlugin.swift](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift) +- 具体 UI:[PlayerView.swift](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift) + +## 在主工程中的调用链路(现状) + +### Android:翻译悬浮窗 + +- 入口:翻译页控制器 [TranslationController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/translation/controllers/translation_controller.dart) + - `toggleFloatingWindow()` → `_enableFloatingWindow()` +- 管理器:[FloatingWindowManager](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/core/services/floating_window_manager.dart) + - `enableFloatingWindow()`:先通过 [OverlayPermissionUtil](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/core/utils/overlay_permission_util.dart) 请求权限,再调用 `TranslationFloatingWindowApi.enable()` + - `_setupMethodCallHandler()`:接收 `onFloatingWindowStateChanged/onFloatingWindowClicked/onFloatingWindowClosed` 并更新状态/路由跳转 +- 内容更新:`TranslationController._updateFloatingWindowWithTranslation()` → `FloatingWindowManager.updateFloatingWindowContent()` → `updateContent(Map)` + +### Android:录音悬浮条 + +- 入口:会议录音控制器 [MeetingRecordController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/meeting/controllers/meeting_record_controller.dart) + - `showBackgroundRecordingIndicator()` → `_startAndroidRecordingFloatingBar()` → `NativePlugin.showRecordingFloatingBar()` + - 定时器每秒 `updateRecordingFloatingBar()` 更新 duration/isPaused + - `_setupRecordingFloatingBarActionListener()` 监听 `recordingFloatingActionStream` 并处理 `togglePause/openMeetingRecord/closeFloatingBar` + +### iOS:翻译悬浮显示(PiP) + +- 入口同样在 [TranslationController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/translation/controllers/translation_controller.dart) + - `_startPip()`:`NativePlugin.createPipContentView()` → 将 viewId 传给第三方 PiP 组件的 `PipOptions.contentView` + - `_updateFloatingWindowWithTranslation()`:`NativePlugin.updatePipContentView({viewId, cn, en, lang})` + +## 权限与 Manifest + +- Android 需要:`android.permission.SYSTEM_ALERT_WINDOW` +- 本插件自带 AndroidManifest 声明(包含两项 service): + - [android/src/main/AndroidManifest.xml](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml) + +## 平台差异与注意事项 + +- Android 与 iOS 的“悬浮显示”实现不同:Android 用系统悬浮窗;iOS 走系统 PiP。 +- `createPipContentView/updatePipContentView/disposePipContentView` 仅 iOS 实现;Android 侧没有实现这些 method,请务必用 `Platform.isIOS` 保护调用。 +- `floating_window` / `overlay_permission` / `native_plugin/recording_floating_actions` 都是固定通道名,避免在其他地方重复注册同名通道。 +- 工程 `android/app/src/main/kotlin/com/saitong/voitrans/` 下存在同名通道的旧实现文件(未在 MainActivity 注册),当前实际走的是本插件实现;如后续要启用 app 侧实现,需要先解决通道名冲突与 service 声明问题。 diff --git a/local_plugins/native_plugin/analysis_options.yaml b/local_plugins/floating_ui_plugin/analysis_options.yaml similarity index 100% rename from local_plugins/native_plugin/analysis_options.yaml rename to local_plugins/floating_ui_plugin/analysis_options.yaml diff --git a/local_plugins/native_plugin/android/.gitignore b/local_plugins/floating_ui_plugin/android/.gitignore similarity index 100% rename from local_plugins/native_plugin/android/.gitignore rename to local_plugins/floating_ui_plugin/android/.gitignore diff --git a/local_plugins/native_plugin/android/build.gradle b/local_plugins/floating_ui_plugin/android/build.gradle similarity index 100% rename from local_plugins/native_plugin/android/build.gradle rename to local_plugins/floating_ui_plugin/android/build.gradle diff --git a/local_plugins/floating_ui_plugin/android/settings.gradle b/local_plugins/floating_ui_plugin/android/settings.gradle new file mode 100644 index 000000000..2f81ceefc --- /dev/null +++ b/local_plugins/floating_ui_plugin/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'floating_ui_plugin' diff --git a/local_plugins/native_plugin/android/src/main/AndroidManifest.xml b/local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml similarity index 100% rename from local_plugins/native_plugin/android/src/main/AndroidManifest.xml rename to local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt similarity index 100% rename from local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt rename to local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt b/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt similarity index 100% rename from local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt rename to local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt b/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt similarity index 100% rename from local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt rename to local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt diff --git a/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java b/local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java similarity index 100% rename from local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java rename to local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java diff --git a/local_plugins/native_plugin/ios/.gitignore b/local_plugins/floating_ui_plugin/ios/.gitignore similarity index 100% rename from local_plugins/native_plugin/ios/.gitignore rename to local_plugins/floating_ui_plugin/ios/.gitignore diff --git a/local_plugins/native_plugin/ios/Assets/.gitkeep b/local_plugins/floating_ui_plugin/ios/Assets/.gitkeep similarity index 100% rename from local_plugins/native_plugin/ios/Assets/.gitkeep rename to local_plugins/floating_ui_plugin/ios/Assets/.gitkeep diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.swift b/local_plugins/floating_ui_plugin/ios/Classes/FLNativeView.swift similarity index 100% rename from local_plugins/native_plugin/ios/Classes/FLNativeView.swift rename to local_plugins/floating_ui_plugin/ios/Classes/FLNativeView.swift diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.swift b/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift similarity index 100% rename from local_plugins/native_plugin/ios/Classes/NativePlugin.swift rename to local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.swift b/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift similarity index 100% rename from local_plugins/native_plugin/ios/Classes/PlayerView.swift rename to local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift diff --git a/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy b/local_plugins/floating_ui_plugin/ios/Resources/PrivacyInfo.xcprivacy similarity index 100% rename from local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy rename to local_plugins/floating_ui_plugin/ios/Resources/PrivacyInfo.xcprivacy diff --git a/local_plugins/native_plugin/ios/floating_ui_plugin.podspec b/local_plugins/floating_ui_plugin/ios/floating_ui_plugin.podspec similarity index 100% rename from local_plugins/native_plugin/ios/floating_ui_plugin.podspec rename to local_plugins/floating_ui_plugin/ios/floating_ui_plugin.podspec diff --git a/local_plugins/native_plugin/lib/native_plugin.dart b/local_plugins/floating_ui_plugin/lib/native_plugin.dart similarity index 100% rename from local_plugins/native_plugin/lib/native_plugin.dart rename to local_plugins/floating_ui_plugin/lib/native_plugin.dart diff --git a/local_plugins/native_plugin/lib/native_plugin_method_channel.dart b/local_plugins/floating_ui_plugin/lib/native_plugin_method_channel.dart similarity index 100% rename from local_plugins/native_plugin/lib/native_plugin_method_channel.dart rename to local_plugins/floating_ui_plugin/lib/native_plugin_method_channel.dart diff --git a/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart b/local_plugins/floating_ui_plugin/lib/native_plugin_platform_interface.dart similarity index 100% rename from local_plugins/native_plugin/lib/native_plugin_platform_interface.dart rename to local_plugins/floating_ui_plugin/lib/native_plugin_platform_interface.dart diff --git a/local_plugins/native_plugin/pubspec.yaml b/local_plugins/floating_ui_plugin/pubspec.yaml similarity index 100% rename from local_plugins/native_plugin/pubspec.yaml rename to local_plugins/floating_ui_plugin/pubspec.yaml diff --git a/local_plugins/native_plugin/README.md b/local_plugins/native_plugin/README.md deleted file mode 100644 index bc8261f01..000000000 --- a/local_plugins/native_plugin/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# native_plugin - -A new Flutter plugin project. - -## Getting Started - -This project is a starting point for a Flutter -[plug-in package](https://flutter.dev/to/develop-plugins), -a specialized package that includes platform-specific implementation code for -Android and/or iOS. - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev), which offers tutorials, -samples, guidance on mobile development, and a full API reference. - diff --git a/local_plugins/native_plugin/android/settings.gradle b/local_plugins/native_plugin/android/settings.gradle deleted file mode 100644 index 9f756a98c..000000000 --- a/local_plugins/native_plugin/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'native_plugin' diff --git a/pubspec.yaml b/pubspec.yaml index a6420566b..e7d6eb828 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -117,7 +117,7 @@ dependencies: qq_music: path: local_plugins/qq_music floating_ui_plugin: - path: local_plugins/native_plugin + path: local_plugins/floating_ui_plugin test: path: local_plugins/test From 12213cd7e1f5924bec8bb880ee311c07e6b1d63e Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 17 Jan 2026 10:28:27 +0800 Subject: [PATCH 13/15] =?UTF-8?q?refactor(android):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E6=82=AC=E6=B5=AE=E7=AA=97=E5=92=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=9D=83=E9=99=90=E7=9B=B8=E5=85=B3=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BB=A3=E7=A0=81=EF=BC=9B=EF=BC=88=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=E5=B7=B2=E7=BB=8F=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E6=9C=AC=E5=9C=B0=E6=8F=92=E4=BB=B6floating?= =?UTF-8?q?=5Fui=5Fplugin=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saitong/voitrans/FloatingWindowPlugin.kt | 133 ------ .../saitong/voitrans/FloatingWindowService.kt | 378 ------------------ .../voitrans/OverlayPermissionPlugin.kt | 99 ----- 3 files changed, 610 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt delete mode 100644 android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt delete mode 100644 android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt deleted file mode 100644 index 9e3e2c7f2..000000000 --- a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.saitong.voitrans - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result - -class FloatingWindowPlugin: FlutterPlugin, MethodCallHandler { - private lateinit var channel: MethodChannel - private lateinit var context: Context - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "floating_window") - channel.setMethodCallHandler(this) - - // 设置回调通道 - FloatingWindowService.methodChannel = channel - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "enableFloatingWindow" -> { - enableFloatingWindow(result) - } - "disableFloatingWindow" -> { - disableFloatingWindow(result) - } - "showFloatingWindow" -> { - showFloatingWindow(result) - } - "hideFloatingWindow" -> { - hideFloatingWindow(result) - } - "updateContent" -> { - updateContent(call, result) - } - "setPosition" -> { - setPosition(call, result) - } - else -> { - result.notImplemented() - } - } - } - - private fun enableFloatingWindow(result: Result) { - try { - val intent = Intent(context, FloatingWindowService::class.java) - context.startService(intent) - result.success(true) - } catch (e: Exception) { - result.error("ENABLE_ERROR", "Failed to enable floating window: ${e.message}", null) - } - } - - private fun disableFloatingWindow(result: Result) { - try { - val intent = Intent(context, FloatingWindowService::class.java) - context.stopService(intent) - result.success(true) - } catch (e: Exception) { - result.error("DISABLE_ERROR", "Failed to disable floating window: ${e.message}", null) - } - } - - private fun showFloatingWindow(result: Result) { - try { - // 服务已经在运行,不需要额外操作 - result.success(true) - } catch (e: Exception) { - result.error("SHOW_ERROR", "Failed to show floating window: ${e.message}", null) - } - } - - private fun hideFloatingWindow(result: Result) { - try { - FloatingWindowService.serviceInstance?.stopSelf() - result.success(true) - } catch (e: Exception) { - result.error("HIDE_ERROR", "Failed to hide floating window: ${e.message}", null) - } - } - - private fun updateContent(call: MethodCall, result: Result) { - try { - val args = call.arguments as? Map - if (args != null) { - val intent = Intent(context, FloatingWindowService::class.java).apply { - action = FloatingWindowService.ACTION_UPDATE_CONTENT - putExtra(FloatingWindowService.EXTRA_SOURCE_TEXT, args["sourceText"] as? String ?: "") - putExtra(FloatingWindowService.EXTRA_TRANSLATED_TEXT, args["translatedText"] as? String ?: "") - putExtra(FloatingWindowService.EXTRA_SOURCE_LANGUAGE, args["sourceLanguage"] as? String ?: "") - putExtra(FloatingWindowService.EXTRA_TARGET_LANGUAGE, args["targetLanguage"] as? String ?: "") - putExtra(FloatingWindowService.EXTRA_TIMESTAMP, (args["timestamp"] as? Number)?.toLong() ?: 0L) - putExtra(FloatingWindowService.EXTRA_IS_INTERMEDIATE, args["isIntermediate"] as? Boolean ?: false) - } - context.startService(intent) - } - result.success(true) - } catch (e: Exception) { - result.error("UPDATE_ERROR", "Failed to update content: ${e.message}", null) - } - } - - private fun setPosition(call: MethodCall, result: Result) { - try { - val args = call.arguments as? Map - if (args != null) { - val intent = Intent(context, FloatingWindowService::class.java).apply { - action = FloatingWindowService.ACTION_SET_POSITION - putExtra(FloatingWindowService.EXTRA_X, (args["x"] as? Number)?.toInt() ?: 0) - putExtra(FloatingWindowService.EXTRA_Y, (args["y"] as? Number)?.toInt() ?: 0) - } - context.startService(intent) - } - result.success(true) - } catch (e: Exception) { - result.error("POSITION_ERROR", "Failed to set position: ${e.message}", null) - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - FloatingWindowService.methodChannel = null - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt deleted file mode 100644 index 296310038..000000000 --- a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt +++ /dev/null @@ -1,378 +0,0 @@ -package com.saitong.voitrans - -import android.app.Service -import android.content.Intent -import android.graphics.PixelFormat -import android.os.Build -import android.os.IBinder -import android.view.* -import android.widget.TextView -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.FrameLayout -import androidx.cardview.widget.CardView -import io.flutter.plugin.common.MethodChannel -import java.text.SimpleDateFormat -import java.util.* - -class FloatingWindowService : Service() { - private var windowManager: WindowManager? = null - private var floatingView: View? = null - private var isMinimized = true - private lateinit var layoutParams: WindowManager.LayoutParams - - private lateinit var sourceTextView: TextView - private lateinit var translatedTextView: TextView - private lateinit var languageInfoView: TextView - private lateinit var timestampView: TextView - private lateinit var minimizedView: CardView - private lateinit var expandedView: CardView - - companion object { - var methodChannel: MethodChannel? = null - var serviceInstance: FloatingWindowService? = null - - const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" - const val ACTION_SET_POSITION = "SET_POSITION" - - const val EXTRA_SOURCE_TEXT = "source_text" - const val EXTRA_TRANSLATED_TEXT = "translated_text" - const val EXTRA_SOURCE_LANGUAGE = "source_language" - const val EXTRA_TARGET_LANGUAGE = "target_language" - const val EXTRA_TIMESTAMP = "timestamp" - const val EXTRA_IS_INTERMEDIATE = "is_intermediate" - const val EXTRA_X = "x" - const val EXTRA_Y = "y" - } - - override fun onCreate() { - super.onCreate() - serviceInstance = this - createFloatingWindow() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_UPDATE_CONTENT -> updateContent(intent) - ACTION_SET_POSITION -> setPosition(intent) - } - return START_STICKY - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun createFloatingWindow() { - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - - // 创建布局 - floatingView = createFloatingLayout() - - // 初始化视图组件 - initViews() - - // 设置点击事件 - setupClickListeners() - - // 设置窗口参数 - layoutParams = WindowManager.LayoutParams().apply { - width = WindowManager.LayoutParams.WRAP_CONTENT - height = WindowManager.LayoutParams.WRAP_CONTENT - type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } else { - WindowManager.LayoutParams.TYPE_PHONE - } - flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - format = PixelFormat.TRANSLUCENT - gravity = Gravity.TOP or Gravity.START - x = 100 - y = 100 - } - - windowManager?.addView(floatingView, layoutParams) - - // 默认显示最小化状态 - updateViewState(true) - } - - private fun createFloatingLayout(): View { - val context = this - val density = resources.displayMetrics.density - - // 创建根布局 - val rootLayout = FrameLayout(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } - - // 创建最小化视图 - val minimizedCard = CardView(context).apply { - id = View.generateViewId() - layoutParams = FrameLayout.LayoutParams( - (60 * density).toInt(), - (60 * density).toInt() - ).apply { - setMargins((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) - } - radius = 30 * density - cardElevation = 8 * density - setCardBackgroundColor(0xFFE3F2FD.toInt()) - } - - val minimizedContent = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - - val icon = TextView(context).apply { - text = "译" - textSize = 14f - setTextColor(0xFF1976D2.toInt()) - gravity = Gravity.CENTER - } - - minimizedContent.addView(icon) - minimizedCard.addView(minimizedContent) - - // 创建展开视图 - val expandedCard = CardView(context).apply { - id = View.generateViewId() - layoutParams = FrameLayout.LayoutParams( - (320 * density).toInt(), - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) - } - radius = 16 * density - cardElevation = 12 * density - setCardBackgroundColor(0xFFFFFFFF.toInt()) - visibility = View.GONE - } - - val expandedContent = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding( - (16 * density).toInt(), - (16 * density).toInt(), - (16 * density).toInt(), - (16 * density).toInt() - ) - } - - // 标题栏 - val titleLayout = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - - val titleText = TextView(context).apply { - text = "实时翻译" - textSize = 16f - setTextColor(0xFF333333.toInt()) - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - } - - val closeButton = TextView(context).apply { - id = View.generateViewId() - text = "✕" - textSize = 18f - setTextColor(0xFF666666.toInt()) - gravity = Gravity.CENTER - setPadding((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) - } - - titleLayout.addView(titleText) - titleLayout.addView(closeButton) - - // 语言信息 - val languageInfo = TextView(context).apply { - id = View.generateViewId() - text = "中文 → 英文" - textSize = 12f - setTextColor(0xFF666666.toInt()) - setPadding(0, (8 * density).toInt(), 0, (8 * density).toInt()) - } - - // 源文本 - val sourceText = TextView(context).apply { - id = View.generateViewId() - text = "正在识别..." - textSize = 14f - setTextColor(0xFF333333.toInt()) - setPadding(0, (8 * density).toInt(), 0, (4 * density).toInt()) - maxLines = 3 - } - - // 翻译文本 - val translatedText = TextView(context).apply { - id = View.generateViewId() - text = "翻译中..." - textSize = 14f - setTextColor(0xFF1976D2.toInt()) - setPadding(0, (4 * density).toInt(), 0, (8 * density).toInt()) - maxLines = 3 - } - - // 时间戳 - val timestamp = TextView(context).apply { - id = View.generateViewId() - text = "" - textSize = 10f - setTextColor(0xFF999999.toInt()) - gravity = Gravity.END - } - - expandedContent.addView(titleLayout) - expandedContent.addView(languageInfo) - expandedContent.addView(sourceText) - expandedContent.addView(translatedText) - expandedContent.addView(timestamp) - expandedCard.addView(expandedContent) - - rootLayout.addView(minimizedCard) - rootLayout.addView(expandedCard) - - // 保存引用 - minimizedView = minimizedCard - expandedView = expandedCard - sourceTextView = sourceText - translatedTextView = translatedText - languageInfoView = languageInfo - timestampView = timestamp - - return rootLayout - } - - private fun initViews() { - // Views are already initialized in createFloatingLayout - } - - private fun setupClickListeners() { - // 长按关闭 - floatingView!!.setOnLongClickListener { - closeFloatingWindow() - true - } - - // 拖拽功能 - setupDragListener() - } - - private fun setupDragListener() { - var initialX = 0 - var initialY = 0 - var initialTouchX = 0f - var initialTouchY = 0f - var dragging = false - val threshold = (4 * resources.displayMetrics.density).toInt() - floatingView!!.setOnTouchListener { _, event -> - when (event.action) { - MotionEvent.ACTION_DOWN -> { - initialX = layoutParams.x - initialY = layoutParams.y - initialTouchX = event.rawX - initialTouchY = event.rawY - dragging = false - true - } - MotionEvent.ACTION_MOVE -> { - val dx = (event.rawX - initialTouchX).toInt() - val dy = (event.rawY - initialTouchY).toInt() - if (Math.abs(dx) > threshold || Math.abs(dy) > threshold) dragging = true - val dm = resources.displayMetrics - val maxX = dm.widthPixels - (floatingView?.width ?: 0) - val maxY = dm.heightPixels - (floatingView?.height ?: 0) - layoutParams.x = (initialX + dx).coerceIn(0, maxX) - layoutParams.y = (initialY + dy).coerceIn(0, maxY) - windowManager?.updateViewLayout(floatingView, layoutParams) - true - } - MotionEvent.ACTION_UP -> { - if (!dragging) { - isMinimized = !isMinimized - updateViewState(isMinimized) - notifyStateChange(if (isMinimized) "minimized" else "expanded") - } - dragging = false - true - } - else -> true - } - } - } - - private fun updateViewState(minimized: Boolean) { - if (minimized) { - minimizedView.visibility = View.VISIBLE - expandedView.visibility = View.GONE - } else { - minimizedView.visibility = View.GONE - expandedView.visibility = View.VISIBLE - } - } - - private fun updateContent(intent: Intent) { - val sourceText = intent.getStringExtra(EXTRA_SOURCE_TEXT) ?: "" - val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) ?: "" - val sourceLanguage = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) ?: "" - val targetLanguage = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) ?: "" - val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0) - val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) - - // 更新UI - sourceTextView.text = if (sourceText.isNotEmpty()) sourceText else "正在识别..." - translatedTextView.text = if (translatedText.isNotEmpty()) translatedText else "翻译中..." - languageInfoView.text = "$sourceLanguage → $targetLanguage" - - if (timestamp > 0) { - val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - timestampView.text = dateFormat.format(Date(timestamp)) - } - - // 如果是中间结果,可以添加一些视觉提示 - if (isIntermediate) { - translatedTextView.alpha = 0.7f - } else { - translatedTextView.alpha = 1.0f - } - } - - private fun setPosition(intent: Intent) { - val x = intent.getIntExtra(EXTRA_X, 0) - val y = intent.getIntExtra(EXTRA_Y, 0) - - floatingView?.let { - layoutParams.x = x - layoutParams.y = y - windowManager?.updateViewLayout(it, layoutParams) - } - } - - private fun closeFloatingWindow() { - stopSelf() - notifyFloatingWindowClosed() - } - - private fun notifyStateChange(state: String) { - methodChannel?.invokeMethod("onFloatingWindowStateChanged", mapOf("state" to state)) - } - - private fun notifyFloatingWindowClosed() { - methodChannel?.invokeMethod("onFloatingWindowClosed", null) - } - - override fun onDestroy() { - floatingView?.let { windowManager?.removeView(it) } - serviceInstance = null - methodChannel = null - super.onDestroy() - } -} diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt b/android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt deleted file mode 100644 index e8496d57a..000000000 --- a/android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.saitong.voitrans - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.content.pm.PackageManager -import android.provider.Settings -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result - -class OverlayPermissionPlugin: FlutterPlugin, MethodCallHandler { - private lateinit var channel: MethodChannel - private lateinit var context: Context - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "overlay_permission") - channel.setMethodCallHandler(this) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "hasOverlayPermission" -> { - result.success(hasOverlayPermission()) - } - "requestOverlayPermission" -> { - requestOverlayPermission(result) - } - else -> { - result.notImplemented() - } - } - } - - private fun hasOverlayPermission(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Settings.canDrawOverlays(context) - } else { - true - } - } - - private fun requestOverlayPermission(result: Result) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (hasOverlayPermission()) { - result.success(true) - return - } - - val openedBySystem = startActivitySafe( - Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { - data = Uri.parse("package:${context.packageName}") - } - ) - if (openedBySystem) { - result.success(true) - return - } - - val openedByAppDetails = startActivitySafe( - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.parse("package:${context.packageName}") - } - ) - if (openedByAppDetails) { - result.success(true) - return - } - - result.success(false) - } else { - result.success(true) - } - } catch (e: Exception) { - result.error("REQUEST_ERROR", "Failed to request overlay permission: ${e.message}", null) - } - } - - private fun startActivitySafe(intent: Intent): Boolean { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - val pm: PackageManager = context.packageManager - if (intent.resolveActivity(pm) == null) return false - return try { - context.startActivity(intent) - true - } catch (_: Exception) { - false - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } -} From 0f5d401e58c4b1d92a1b393f2a5a1d6e9e89bd3b Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 17 Jan 2026 13:05:59 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix(settings):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=9C=B0=E5=9D=80=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=94=99=E8=AF=AF=E7=9A=84baseUrl=E5=B1=9E?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/settings/controllers/settings_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index a81b53f26..4cbc4de90 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -404,7 +404,7 @@ class SettingsController extends GetxController { 最近版本检查时间: ${versionService.lastCheckTime ?? '未检查'} 国家地区编码: ${versionService.cachedCountryCode} 语言编码: ${versionService.cachedLanguageCode} -服务器地址: ${DioManager().baseUrl} +服务器地址: ${DioManager().dio.options.baseUrl} '''; } catch (e) { return '获取版本服务数据失败: $e'; From 93979e8ae835d181637b603585eb583fea60ac6f Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 17 Jan 2026 15:51:49 +0800 Subject: [PATCH 15/15] =?UTF-8?q?Revert=20"=E6=8F=90=E5=89=8D=E5=90=A7?= =?UTF-8?q?=E5=90=88=E6=88=90=E6=95=B0=E6=8D=AE=E5=8F=91=E9=80=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 837b87064495272a5e4216f9a5c7e1cd96dbd76c. --- .../azure_speech/AzureAsrToAsr.kt | 54 ++++--------------- .../azure_speech/AzureSpeechPlugin.kt | 8 +-- .../azure_speech/AzureSpeechPlugin.swift | 8 +-- .../IntegratedSpeechTranslationService.swift | 54 +++++-------------- 4 files changed, 29 insertions(+), 95 deletions(-) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index 070b042ed..b8e5e3056 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -80,36 +80,6 @@ class IntegratedSpeechTranslationService( // 语音映射缓存 private val voiceCache = ConcurrentHashMap() - - - // TTS合成中的音频缓存 - private var ttsdata: ByteArray = ByteArray(0) - - /** - * 处理合成音频分片(按1280字节对齐) - * 输入的音频数据会追加到缓存;仅当累计长度达到`multiple`的整数倍时, - * 将该整数倍长度的部分通过`onSynthesisAudioGenerated`发送;余数保留在缓存等待下次。 - */ - private fun processSynthesisAudioChunk( - text: String, - incoming: ByteArray, - multiple: Int = 1280 - ) { - if (incoming.isNotEmpty()) { - ttsdata = ttsdata + incoming - } - - val sendLen = (ttsdata.size / multiple) * multiple - if (sendLen > 0) { - val chunk = ttsdata.copyOfRange(0, sendLen) - Log.d(TAG, "按${multiple}字节对齐发送音频: ${chunk.size} 字节,缓存剩余: ${ttsdata.size - sendLen}") - eventCallback?.onSynthesisAudioGenerated(text, chunk) - ttsdata = ttsdata.copyOfRange(sendLen, ttsdata.size) - } else { - Log.d(TAG, "暂未够${multiple}字节,当前缓存: ${ttsdata.size}") - } - } - /** * 服务配置类 */ @@ -429,7 +399,6 @@ class IntegratedSpeechTranslationService( // 合成开始事件 SynthesisStarted.addEventListener { _, _ -> Log.d(TAG, "合成开始事件") - ttsdata = ByteArray(0) serviceState.isSynthesizing.set(true) eventCallback?.onStateChanged("Synthesis", true) } @@ -437,11 +406,9 @@ class IntegratedSpeechTranslationService( // 合成进行中事件 Synthesizing.addEventListener { _, event -> Log.d(TAG, "合成进行中事件") - // 计算进度(简化版) - val progress = 0.5f // 实际应用中可以根据音频数据计算 + // 计算进度(简化版) + val progress = 0.5f // 实际应用中可以根据音频数据计算 eventCallback?.onSynthesisProgress("", progress) - val incoming = event.result.audioData ?: ByteArray(0) - processSynthesisAudioChunk("", incoming, 1280) } // 合成完成事件 @@ -454,7 +421,6 @@ class IntegratedSpeechTranslationService( // 合成取消事件 SynthesisCanceled.addEventListener { _, event -> Log.d(TAG, "合成取消事件") - ttsdata = ByteArray(0) serviceState.isSynthesizing.set(false) val reason = event.result.reason eventCallback?.onError("Synthesis", "语音合成取消: $reason") @@ -639,7 +605,6 @@ class IntegratedSpeechTranslationService( try { Log.d(TAG, "开始语音合成: $text") serviceState.isSynthesizing.set(true) - eventCallback?.onSynthesisStarted(text) val ssml = generateOptimizedSsml(text) @@ -653,13 +618,12 @@ class IntegratedSpeechTranslationService( // 获取音频数据 val audioData = result.audioData - // if (audioData != null && audioData.isNotEmpty()) { - // // 通过回调返回音频数据 - // eventCallback?.onSynthesisAudioGenerated(text, audioData) - // Log.d(TAG, "音频数据大小: ${audioData.size} 字节") - // } - eventCallback?.onSynthesisAudioGenerated(text, ttsdata) - ttsdata = ByteArray(0) + if (audioData != null && audioData.isNotEmpty()) { + // 通过回调返回音频数据 + eventCallback?.onSynthesisAudioGenerated(text, audioData) + Log.d(TAG, "音频数据大小: ${audioData.size} 字节") + } + return audioData } else { Log.e(TAG, "语音合成失败: ${result?.reason}") @@ -1769,4 +1733,4 @@ class VolcanoTranslationServiceImpl : Log.e(TAG, "清理资源失败", e) } } -} +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index a080f4562..702f91aca 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -1042,10 +1042,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onSynthesisAudioGenerated(text: String, audioData: ByteArray) { FileLogger.d(tag, "语音合成音频生成,文本=${text},音频数据大小=${audioData.size}") // Azure TTS返回的是RIFF WAV格式,需要去除文件头 - //val pcmData = extractPcmFromWav(audioData) - if (audioData.isNotEmpty()) { - FileLogger.d(tag, "提取PCM数据,大小=${audioData.size}字节") - BleService.writeExternalAudioData(audioData) + val pcmData = extractPcmFromWav(audioData) + if (pcmData.isNotEmpty()) { + FileLogger.d(tag, "提取PCM数据,大小=${pcmData.size}字节") + BleService.writeExternalAudioData(pcmData) } else { FileLogger.e(tag, "提取PCM数据失败,音频数据可能不是WAV格式") diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index d6324d393..c74abc624 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -1039,10 +1039,10 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC os_log("语音合成音频生成,文本=%@,音频数据大小=%d", log: plugin.log, type: .info, text, audioData.count) // Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据 - // let pcmData = plugin.extractPcmFromWav(audioData) - if !audioData.isEmpty { - os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, audioData.count) - BleService.shared.writeExternalAudioData(data: audioData) + let pcmData = plugin.extractPcmFromWav(audioData) + if !pcmData.isEmpty { + os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, pcmData.count) + BleService.shared.writeExternalAudioData(data: pcmData) } else { os_log("提取PCM数据失败,音频数据可能不是WAV格式", log: plugin.log, type: .error) } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift index a404cf89a..97db886f4 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift @@ -66,9 +66,6 @@ import os.log // 处理队列 private let processingQueue = DispatchQueue(label: "com.azure.speech.processing", qos: .userInitiated) - // 合成中的音频缓存 - private var ttsBuffer = Data() - // MARK: - Configuration Structures /** @@ -481,7 +478,6 @@ import os.log // 合成开始事件 synthesizer?.addSynthesisStartedEventHandler { [weak self] (synthesizer, event) in guard let self = self else { return } - self.ttsBuffer = Data() os_log("合成开始事件", log: self.log, type: .info) self.serviceState.isSynthesizing = true @@ -491,7 +487,7 @@ import os.log } } - // 合成进行中事件 - 增量发送音频分片(按1280字节对齐) + // 合成进行中事件 - 修改为返回音频数据 synthesizer?.addSynthesizingEventHandler { [weak self] (synthesizer, event) in guard let self = self else { return } // 安全解包audioData @@ -499,8 +495,8 @@ import os.log os_log("合成进行中事件,音频数据为空", log: self.log, type: .debug) return } - self.processSynthesisAudioChunk(text: "", incoming: audioData, multiple: 1280) - os_log("合成进行中事件,累计缓存长度: %d bytes", log: self.log, type: .debug, self.ttsBuffer.count) + os_log("合成进行中事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count) + } // 合成完成事件 @@ -509,14 +505,16 @@ import os.log os_log("合成完成事件", log: self.log, type: .info) // 安全解包audioData - - // 发送缓存中的剩余数据(可能非1280对齐的余量) - let remaining = self.ttsBuffer - self.ttsBuffer = Data() + guard let audioData = event.result.audioData else { + os_log("合成完成事件,音频数据为空", log: self.log, type: .debug) + return + } + os_log("合成完成事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count) DispatchQueue.main.async { - self.serviceState.isSynthesizing = false - self.eventCallback?.onSynthesisAudioGenerated(text: "", audioData: remaining) - self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false) + self.serviceState.isSynthesizing = false + // 立即返回当前生成的音频数据 + self.eventCallback?.onSynthesisAudioGenerated(text: "", audioData: audioData) + self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false) } } @@ -527,8 +525,6 @@ import os.log os_log("合成取消事件", log: self.log, type: .info) self.serviceState.isSynthesizing = false - // 取消时清空缓存,避免脏数据 - self.ttsBuffer = Data() // 修复:移除可选链,因为event.result不是可选类型 let reason = event.result.reason @@ -546,32 +542,6 @@ import os.log } } - /** - * 处理合成音频分片(按multiple字节对齐,默认1280) - * - 参数 text: 正在合成的文本 - * - 参数 incoming: 新到达的音频数据块 - * - 参数 multiple: 对齐字节倍数(例如16000Hz/16bit/mono下常用1280) - * 将incoming追加到缓存,仅当累计长度达到multiple的整数倍时, - * 发送对齐部分;余数继续保留在缓存,等待后续数据到来或合成完成时一次性发送。 - */ - private func processSynthesisAudioChunk(text: String, incoming: Data?, multiple: Int = 1280) { - guard let incoming = incoming, !incoming.isEmpty else { return } - // 追加到缓存 - ttsBuffer.append(incoming) - // 计算可发送的对齐长度 - let sendLen = (ttsBuffer.count / multiple) * multiple - if sendLen > 0 { - let chunk = ttsBuffer.prefix(sendLen) - // 发送对齐部分 - let toSend = Data(chunk) - DispatchQueue.main.async { [weak self] in - self?.eventCallback?.onSynthesisAudioGenerated(text: text, audioData: toSend) - } - // 保留余数 - ttsBuffer = Data(ttsBuffer.suffix(ttsBuffer.count - sendLen)) - } - } - // MARK: - Public Methods /**