Browse Source

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

newdev_shunjiawei
liwei1dao 9 months ago
parent
commit
9832e8c6fb
  1. 14
      android/app/src/main/AndroidManifest.xml
  2. 133
      android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt
  3. 378
      android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt
  4. 277
      android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt
  5. 99
      android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt
  6. 40
      lib/core/services/floating_window_manager.dart
  7. 27
      lib/data/services/meeting/meeting_task_service.dart
  8. 105
      lib/data/services/meeting/meeting_upload_service.dart
  9. 50
      lib/main.dart
  10. 12
      lib/modules/meeting/controllers/meeting_details_controller.dart
  11. 31
      lib/modules/meeting/controllers/meeting_home_controller.dart
  12. 3
      lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart
  13. 18
      lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  14. 6
      lib/modules/meeting/views/meeting_home_view.dart
  15. 5
      lib/modules/meeting/views/meeting_record_view.dart
  16. 54
      lib/modules/meeting/views/tabs/overview_tab.dart
  17. 479
      lib/modules/meeting/views/tabs/speech_tab.dart
  18. 74
      lib/modules/meeting/views/tabs/summary_tab.dart
  19. 2
      lib/modules/settings/controllers/settings_controller.dart
  20. 49
      lib/modules/translation/controllers/translation_controller.dart
  21. 52
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt
  22. 8
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  23. 8
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  24. 54
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift
  25. 0
      local_plugins/floating_ui_plugin/.gitignore
  26. 0
      local_plugins/floating_ui_plugin/.metadata
  27. 0
      local_plugins/floating_ui_plugin/CHANGELOG.md
  28. 0
      local_plugins/floating_ui_plugin/LICENSE
  29. 120
      local_plugins/floating_ui_plugin/README.md
  30. 0
      local_plugins/floating_ui_plugin/analysis_options.yaml
  31. 0
      local_plugins/floating_ui_plugin/android/.gitignore
  32. 0
      local_plugins/floating_ui_plugin/android/build.gradle
  33. 1
      local_plugins/floating_ui_plugin/android/settings.gradle
  34. 0
      local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml
  35. 21
      local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt
  36. 0
      local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt
  37. 0
      local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt
  38. 0
      local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java
  39. 0
      local_plugins/floating_ui_plugin/ios/.gitignore
  40. 0
      local_plugins/floating_ui_plugin/ios/Assets/.gitkeep
  41. 0
      local_plugins/floating_ui_plugin/ios/Classes/FLNativeView.swift
  42. 0
      local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift
  43. 220
      local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift
  44. 0
      local_plugins/floating_ui_plugin/ios/Resources/PrivacyInfo.xcprivacy
  45. 0
      local_plugins/floating_ui_plugin/ios/floating_ui_plugin.podspec
  46. 0
      local_plugins/floating_ui_plugin/lib/native_plugin.dart
  47. 0
      local_plugins/floating_ui_plugin/lib/native_plugin_method_channel.dart
  48. 0
      local_plugins/floating_ui_plugin/lib/native_plugin_platform_interface.dart
  49. 0
      local_plugins/floating_ui_plugin/pubspec.yaml
  50. 15
      local_plugins/native_plugin/README.md
  51. 1
      local_plugins/native_plugin/android/settings.gradle
  52. 98
      local_plugins/native_plugin/ios/Classes/PlayerView.swift
  53. 2
      pubspec.yaml

14
android/app/src/main/AndroidManifest.xml

@ -111,6 +111,20 @@
<data android:scheme="voitrans"/> <data android:scheme="voitrans"/>
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="audio/*" />
<data android:scheme="file" android:mimeType="audio/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/*" />
</intent-filter>
<!-- PayPal 网页回跳的深链:deepvoiceclient://paypal/return 与 /cancel --> <!-- PayPal 网页回跳的深链:deepvoiceclient://paypal/return 与 /cancel -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>

133
android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt

@ -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<String, Any>
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<String, Any>
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
}
}

378
android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt

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

277
android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt

@ -15,15 +15,25 @@ import android.os.Looper
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel // 添加这行导入 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() { class MainActivity: FlutterActivity() {
private val TAG = "MainActivity" private val TAG = "MainActivity"
private var pendingDeepLink: Intent? = null private var pendingDeepLink: Intent? = null
private var pendingNavigationIntent: 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 EXTRA_NAVIGATE_TO = "com.saitong.voitrans.extra.NAVIGATE_TO"
private val NAVIGATE_TO_MEETING_RECORD = "meetingRecord" private val NAVIGATE_TO_MEETING_RECORD = "meetingRecord"
private val ROUTE_MEETING_RECORD = "/meeting/record" 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?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "🚀 MainActivity onCreate 开始") Log.d(TAG, "🚀 MainActivity onCreate 开始")
@ -36,10 +46,20 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "Intent extras: ${it.extras}") Log.d(TAG, "Intent extras: ${it.extras}")
} }
// 暂存深链接,等Flutter引擎完全准备好后处理 intent?.let {
if (intent?.action == Intent.ACTION_VIEW && intent?.data != null) { if (it.action == Intent.ACTION_SEND) {
pendingDeepLink = intent pendingShareIntent = it
Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成") 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) { if (intent?.hasExtra(EXTRA_NAVIGATE_TO) == true) {
@ -63,8 +83,28 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "New Intent data: ${intent.data}") Log.d(TAG, "New Intent data: ${intent.data}")
Log.d(TAG, "New Intent extras: ${intent.extras}") Log.d(TAG, "New Intent extras: ${intent.extras}")
// 处理深链接 intent.let {
handleNavigationIntent(intent) 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? { override fun provideFlutterEngine(context: Context): FlutterEngine? {
@ -110,15 +150,21 @@ class MainActivity: FlutterActivity() {
// Flutter引擎配置完成后,处理待处理的深链接 // Flutter引擎配置完成后,处理待处理的深链接
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper()).postDelayed({
pendingShareIntent?.let { intent ->
Log.d(TAG, "📎 处理待处理的分享/打开文件意图")
deliverOrQueueIntent(intent)
pendingShareIntent = null
}
pendingDeepLink?.let { intent -> pendingDeepLink?.let { intent ->
Log.d(TAG, "🔗 处理待处理的深链接") Log.d(TAG, "🔗 处理待处理的深链接")
handleNavigationIntent(intent) deliverOrQueueIntent(intent)
pendingDeepLink = null pendingDeepLink = null
} }
pendingNavigationIntent?.let { intent -> pendingNavigationIntent?.let { intent ->
Log.d(TAG, "🧭 处理待处理的导航意图") Log.d(TAG, "🧭 处理待处理的导航意图")
handleNavigationIntent(intent) deliverOrQueueIntent(intent)
pendingNavigationIntent = null pendingNavigationIntent = null
} }
}, 1000) // 延迟1秒确保Flutter完全准备好 }, 1000) // 延迟1秒确保Flutter完全准备好
@ -126,46 +172,205 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "✅ configureFlutterEngine 完成") Log.d(TAG, "✅ configureFlutterEngine 完成")
} }
// 处理深链接的统一方法 private fun deliverOrQueueIntent(intent: Intent?) {
private fun handleNavigationIntent(intent: Intent?) { intent ?: return
intent?.let {
val navigateTo = it.getStringExtra(EXTRA_NAVIGATE_TO) val navigateTo = intent.getStringExtra(EXTRA_NAVIGATE_TO)
if (navigateTo == NAVIGATE_TO_MEETING_RECORD) { if (navigateTo == NAVIGATE_TO_MEETING_RECORD) {
pushRouteToFlutter(ROUTE_MEETING_RECORD) pushRouteToFlutter(ROUTE_MEETING_RECORD)
return 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) { if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
val uri = it.data val uri = intent.data
Log.d(TAG, "🔗 处理深链接: $uri") Log.d(TAG, "🔗 处理深链接: $uri")
pendingDeepLinkUrl = uri.toString()
deliverPendingDeepLink()
return
}
try { Log.d(TAG, "🔗 Intent 不是深链接或数据为空")
Log.d(TAG, "🔗 开始发送深链接到 Flutter") }
// 通过 MethodChannel 将深链接信息传递给 Flutter private fun getEngineForMessaging(): FlutterEngine? {
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> return flutterEngine ?: FlutterEngineCache.getInstance().get("main_engine")
Log.d(TAG, "🔗 获取到 BinaryMessenger") }
val channel = MethodChannel( private fun deliverPendingSharedFile(attempt: Int = 0) {
messenger, val filePath = pendingSharedFilePath ?: return
"com.saitong.voitrans/deeplink" 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 arguments = buildUrlArguments(uri.toString()) val channel = MethodChannel(messenger, DEEPLINK_CHANNEL)
channel.invokeMethod(
"handleSharedFile",
mapOf("filePath" to filePath),
object : MethodChannel.Result {
override fun success(result: Any?) {
pendingSharedFilePath = null
}
Log.d(TAG, "🔗 准备发送参数: $arguments") override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
Log.e(TAG, "❌ Flutter 处理分享文件返回错误:$errorCode, $errorMessage")
}
channel.invokeMethod("handleDeepLink", arguments) override fun notImplemented() {
Log.d(TAG, "✅ 深链接信息已发送到 Flutter") if (attempt < MAX_DELIVERY_RETRIES) {
} ?: run { Handler(Looper.getMainLooper()).postDelayed(
Log.w(TAG, "❌ BinaryMessenger 为空,无法发送深链接") { deliverPendingSharedFile(attempt + 1) },
DELIVERY_RETRY_DELAY_MS
)
} 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 { } 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 -> {
val extraStream = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
extraStream ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri
}
Intent.ACTION_SEND_MULTIPLE -> {
val items = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
items?.firstOrNull() ?: intent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri
}
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")
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 = 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)
null
} }
} }

99
android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt

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

40
lib/core/services/floating_window_manager.dart

@ -2,8 +2,11 @@ import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:floating_ui_plugin/native_plugin.dart'; import 'package:floating_ui_plugin/native_plugin.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../utils/overlay_permission_util.dart'; import '../utils/overlay_permission_util.dart';
import '../../routes/app_routes.dart';
import '../../modules/translation/controllers/translation_controller.dart';
/// 悬浮窗状态 /// 悬浮窗状态
enum FloatingWindowState { enum FloatingWindowState {
@ -48,6 +51,9 @@ class FloatingWindowManager extends GetxController {
static MethodChannel get _channel => TranslationFloatingWindowApi.channel; static MethodChannel get _channel => TranslationFloatingWindowApi.channel;
final TranslationFloatingWindowApi _api = TranslationFloatingWindowApi(); final TranslationFloatingWindowApi _api = TranslationFloatingWindowApi();
static const String _lastTranslationModeKey =
'floating_window_last_translation_mode';
final GetStorage _storage = GetStorage();
final _isEnabled = false.obs; final _isEnabled = false.obs;
final _currentState = FloatingWindowState.hidden.obs; final _currentState = FloatingWindowState.hidden.obs;
@ -79,7 +85,7 @@ class FloatingWindowManager extends GetxController {
break; break;
case 'onFloatingWindowClicked': case 'onFloatingWindowClicked':
// 悬浮窗被点击,可以执行相应操作(如返回主应用) // 悬浮窗被点击,可以执行相应操作(如返回主应用)
_handleFloatingWindowClick(); await _handleFloatingWindowClick();
break; break;
} }
}); });
@ -194,9 +200,33 @@ class FloatingWindowManager extends GetxController {
} }
/// 处理悬浮窗点击事件 /// 处理悬浮窗点击事件
void _handleFloatingWindowClick() { Future<void> _handleFloatingWindowClick() async {
// 这里可以实现点击悬浮窗后的操作,比如返回主应用 final String mode = getLastTranslationMode();
Logger.info('悬浮窗被点击'); Logger.info('悬浮窗语言区域被点击,返回翻译页面: $mode');
// 可以发送事件给其他组件处理
if (Get.isRegistered<TranslationController>()) {
final controller = Get.find<TranslationController>();
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<String>(_lastTranslationModeKey);
return _normalizeTranslationMode(mode);
}
String _normalizeTranslationMode(String? mode) {
if (mode == 'audioVideo' || mode == 'call') return mode!;
return 'call';
} }
} }

27
lib/data/services/meeting/meeting_task_service.dart

@ -1,9 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../db/sqflite_api.dart'; import '../db/sqflite_api.dart';
import '../network/api.dart'; import '../network/api.dart';
import '../../models/user_Info.dart';
import 'meeting_upload_service.dart'; import 'meeting_upload_service.dart';
class MeetingTaskService extends GetxService { class MeetingTaskService extends GetxService {
@ -81,6 +83,8 @@ class MeetingTaskService extends GetxService {
'isdistinguishspeaker': isdistinguishspeaker, 'isdistinguishspeaker': isdistinguishspeaker,
'templateid': templateid, 'templateid': templateid,
}); });
final seconds = await _getMeetingSeconds(id);
_decrementMeetingIntegral(cost: seconds);
_taskList.add({ _taskList.add({
'id': id, 'id': id,
'tasktype': 1, 'tasktype': 1,
@ -207,4 +211,27 @@ class MeetingTaskService extends GetxService {
} }
} }
} }
/// 获取会议音频秒数
Future<int> _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());
}
} }

105
lib/data/services/meeting/meeting_upload_service.dart

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
@ -21,6 +22,9 @@ class MeetingUploadService extends GetxService {
bool _isUpload = false; bool _isUpload = false;
int _handled = 0; //队列处理指针,表示当前循环语句处理的任务数量 int _handled = 0; //队列处理指针,表示当前循环语句处理的任务数量
static const Duration _fileStableTimeout = Duration(seconds: 20);
static const Duration _fileStablePoll = Duration(milliseconds: 350);
static const int _fileStableNeededCount = 3;
@override @override
void onInit() { void onInit() {
@ -80,8 +84,26 @@ class MeetingUploadService extends GetxService {
meetingId.value = element['id']; meetingId.value = element['id'];
try { try {
int totalProgress = 0; 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( final audiourl = await UploadOss.upload(
filepath: element['filepath'], filepath: filePath,
rootDir: element['rootdir'], rootDir: element['rootdir'],
onSendProgress: (int count, int total) { onSendProgress: (int count, int total) {
// uploadProgress.value = count / total; // uploadProgress.value = count / total;
@ -139,6 +161,87 @@ class MeetingUploadService extends GetxService {
_isUpload = false; _isUpload = false;
} }
Future<int> _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<bool> _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<int> 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}) { void removeUpload(int id, {bool isDeleteTask = false}) {
Map uploadData = uploadList.firstWhere( Map uploadData = uploadList.firstWhere(
(element) => element['id'] == id, (element) => element['id'] == id,

50
lib/main.dart

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -19,8 +20,11 @@ import 'core/themes/theme_service.dart';
import 'core/bindings/initial_binding.dart'; import 'core/bindings/initial_binding.dart';
import 'data/services/login_service.dart'; import 'data/services/login_service.dart';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'modules/meeting/controllers/meeting_home_controller.dart';
import 'modules/splash/controllers/splash_controller.dart'; import 'modules/splash/controllers/splash_controller.dart';
bool _deepLinkHandlerInitialized = false;
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -46,6 +50,9 @@ void main() async {
// 初始化深链接处理器 // 初始化深链接处理器
void _initDeepLinkHandler() { void _initDeepLinkHandler() {
if (_deepLinkHandlerInitialized) return;
_deepLinkHandlerInitialized = true;
print("🔗 初始化深链接处理器"); print("🔗 初始化深链接处理器");
const platform = MethodChannel('com.saitong.voitrans/deeplink'); const platform = MethodChannel('com.saitong.voitrans/deeplink');
@ -62,6 +69,15 @@ void _initDeepLinkHandler() {
print("🔗 处理深链接 URL: $url"); print("🔗 处理深链接 URL: $url");
await _handleDeepLinkNavigation(url); await _handleDeepLinkNavigation(url);
} }
return;
}
if (call.method == 'handleSharedFile') {
final arguments = call.arguments as Map<dynamic, dynamic>;
final filePath = arguments['filePath'] as String?;
if (filePath == null || filePath.isEmpty) return;
await _handleSharedAudioFile(filePath);
return;
} }
}); });
@ -107,6 +123,40 @@ Future<void> _handleDeepLinkNavigation(String url) async {
} }
} }
Future<void> _handleSharedAudioFile(String filePath) async {
try {
if (!File(filePath).existsSync()) {
print("❌ 分享文件不存在: $filePath");
return;
}
int retryCount = 0;
const maxRetries = 10;
while (retryCount < maxRetries) {
try {
if (Get.isRegistered<MeetingHomeController>()) {
final controller = Get.find<MeetingHomeController>();
controller.addAudio(filePath);
return;
}
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 { class MainApp extends StatelessWidget {
const MainApp({super.key}); const MainApp({super.key});

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

@ -57,7 +57,7 @@ class MeetingDetailsController extends GetxController
RxInt editSpeakerTextId = 0.obs; RxInt editSpeakerTextId = 0.obs;
String editSpeakerTextChange = ''; String editSpeakerTextChange = '';
int _everType = 99; int _everType = 99999;
RxInt summaryNumberModifications = 0.obs; RxInt summaryNumberModifications = 0.obs;
RxInt personnelNumberModifications = 0.obs; RxInt personnelNumberModifications = 0.obs;
@ -94,12 +94,12 @@ class MeetingDetailsController extends GetxController
_tasktypeListener = ever(_meetingTaskService.tasktype, (value) { _tasktypeListener = ever(_meetingTaskService.tasktype, (value) {
meetingDetails.value.tasktype = value; meetingDetails.value.tasktype = value;
if (_everType <= value) { if (_everType <= value) {
if (value >= 5) { if (value >= 5 && value < 10000) {
meetingDetails.value.personnel = _meetingTaskService.personnel; meetingDetails.value.personnel = _meetingTaskService.personnel;
meetingDetails.value.summary = _meetingTaskService.summary; meetingDetails.value.summary = _meetingTaskService.summary;
meetingDetails.value.overview = _meetingTaskService.overview; meetingDetails.value.overview = _meetingTaskService.overview;
summaryNumberModifications.value++; summaryNumberModifications.value++;
} else if (value >= 3) { } else if (value >= 3 && value < 10000) {
textList.value = _meetingTaskService.textList; textList.value = _meetingTaskService.textList;
} }
} }
@ -132,14 +132,16 @@ class MeetingDetailsController extends GetxController
if (meetingDetails.value.tasktype == 5) { if (meetingDetails.value.tasktype == 5) {
await _meetingTaskService.readTask(id); await _meetingTaskService.readTask(id);
} }
if (meetingDetails.value.tasktype >= 5) { if (meetingDetails.value.tasktype >= 5 &&
meetingDetails.value.tasktype < 10000) {
dragOffset.value = 0; dragOffset.value = 0;
isTop.value = true; isTop.value = true;
} }
if (meetingData.value.filepath.isNotEmpty) { if (meetingData.value.filepath.isNotEmpty) {
await _loadPlayerController(); await _loadPlayerController();
} }
if (meetingDetails.value.tasktype >= 3) { if (meetingDetails.value.tasktype >= 3 &&
meetingDetails.value.tasktype < 10000) {
List speakerList = await SqfliteApi.getMeetingSpeaker(id); List speakerList = await SqfliteApi.getMeetingSpeaker(id);
textList.value = List.from(speakerList); textList.value = List.from(speakerList);
} }

31
lib/modules/meeting/controllers/meeting_home_controller.dart

@ -260,8 +260,15 @@ class MeetingHomeController extends GetxController {
final creationTime = fileStat.changed.millisecondsSinceEpoch; final creationTime = fileStat.changed.millisecondsSinceEpoch;
// 准备新路径 // 准备新路径
final fileName = filePath.split('/').last; //获取文件名(包含扩展名) String fileName = filePath.split('/').last; //获取文件名(包含扩展名)
final newPath = "${destDir.path}/$fileName"; //构建新路径,保持原文件名 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<String, Object?> data = { Map<String, Object?> data = {
@ -277,6 +284,7 @@ class MeetingHomeController extends GetxController {
// 插入数据库 // 插入数据库
final response = await Api.addRecordEchomeet({ final response = await Api.addRecordEchomeet({
'rtype': 'LOCAL', 'rtype': 'LOCAL',
'size': fileStat.size,
...data, ...data,
}); });
final meetingId = response['record']['id']; final meetingId = response['record']['id'];
@ -285,6 +293,13 @@ class MeetingHomeController extends GetxController {
...data, ...data,
}); });
if (meetingResult != null) { if (meetingResult != null) {
// 移动文件:优先 rename,失败再 copy+delete,确保上传读取的是完整目标文件
try {
await file.rename(newPath);
} catch (_) {
await file.copy(newPath);
await file.delete();
}
// 安排后台上传任务 // 安排后台上传任务
meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio'); meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio');
@ -305,10 +320,6 @@ class MeetingHomeController extends GetxController {
}); });
} }
// 移动文件
await file.copy(newPath); // 复制到新路径
await file.delete(); // 删除原文件
addUp++; addUp++;
} }
} catch (e) { } catch (e) {
@ -408,6 +419,7 @@ class MeetingHomeController extends GetxController {
final response = await Api.addRecordEchomeet({ final response = await Api.addRecordEchomeet({
'rtype': recordType, 'rtype': recordType,
'size': fileStat.size,
...data, ...data,
}); });
final meetingId = response['record']['id']; final meetingId = response['record']['id'];
@ -521,6 +533,7 @@ class MeetingHomeController extends GetxController {
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); await _audioPlayer.setAudioSource(AudioSource.file(filePath));
int seconds = _audioPlayer.duration?.inSeconds ?? 0; int seconds = _audioPlayer.duration?.inSeconds ?? 0;
int creationTime = DateTime.now().millisecondsSinceEpoch; int creationTime = DateTime.now().millisecondsSinceEpoch;
final fileStat = await File(filePath).stat();
Map<String, Object?> data = { Map<String, Object?> data = {
'title': fileName, 'title': fileName,
'type': type, // 使用传入的type参数 'type': type, // 使用传入的type参数
@ -530,7 +543,11 @@ class MeetingHomeController extends GetxController {
'tasktype': 0, 'tasktype': 0,
'creationtime': creationTime, '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 meetingId = response['record']['id'];
final meetingResult = await SqfliteApi.insertMeeting({ final meetingResult = await SqfliteApi.insertMeeting({
'id': meetingId, 'id': meetingId,

3
lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart

@ -26,7 +26,8 @@ class _MoreBottomSheetState extends State<MoreBottomSheet> {
children: [ children: [
SizedBox(height: 10.w), SizedBox(height: 10.w),
_shareItem(Icons.refresh, 'reSummarize'.tr, //重新总结 _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.back();
Get.bottomSheet( Get.bottomSheet(

18
lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart

@ -117,7 +117,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
]), ]),
_box([ _box([
_shareItem(Icons.copy, 'copyTranscript'.tr, // 对应中文:复制转写 _shareItem(Icons.copy, 'copyTranscript'.tr, // 对应中文:复制转写
onTap: _controller.meetingDetails.value.tasktype >= 3 onTap: _controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10002
? () { ? () {
Get.back(); Get.back();
_copy(_getAllText()); _copy(_getAllText());
@ -125,7 +126,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
: null), : null),
_shareItem(Icons.copy, 'copySummary'.tr, // 对应中文:复制总结 _shareItem(Icons.copy, 'copySummary'.tr, // 对应中文:复制总结
isBorder: false, isBorder: false,
onTap: _controller.meetingDetails.value.tasktype >= 5 onTap: _controller.meetingDetails.value.tasktype >= 5 &&
_controller.meetingDetails.value.tasktype < 10001
? () { ? () {
Get.back(); Get.back();
_copy(_markdownToPlainText( _copy(_markdownToPlainText(
@ -142,7 +144,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
'shareMeetingAudio'.tr); // 对应中文:分享会议音频 'shareMeetingAudio'.tr); // 对应中文:分享会议音频
}), }),
_shareItem(Icons.message, 'exportTranscript'.tr, // 对应中文:导出转写 _shareItem(Icons.message, 'exportTranscript'.tr, // 对应中文:导出转写
onTap: _controller.meetingDetails.value.tasktype >= 3 onTap: _controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10002
? () async { ? () async {
Get.back(); Get.back();
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
@ -155,7 +158,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
} }
: null), : null),
_shareItem(Icons.article, 'exportSummary'.tr, // 对应中文:导出总结 _shareItem(Icons.article, 'exportSummary'.tr, // 对应中文:导出总结
onTap: _controller.meetingDetails.value.tasktype >= 5 onTap: _controller.meetingDetails.value.tasktype >= 5 &&
_controller.meetingDetails.value.tasktype < 10001
? () async { ? () async {
Get.back(); Get.back();
Get.bottomSheet( Get.bottomSheet(
@ -205,7 +209,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
_shareItem( _shareItem(
Icons.save_alt, Icons.save_alt,
'保存思维导图', '保存思维导图',
onTap: _controller.meetingDetails.value.tasktype >= 5 onTap: _controller.meetingDetails.value.tasktype >= 5 &&
_controller.meetingDetails.value.tasktype < 10001
? () { ? () {
Get.back(); Get.back();
final mindMapController = final mindMapController =
@ -233,7 +238,8 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
Icons.lan, Icons.lan,
'shareMindMap'.tr, // 对应中文:分享思维导图 'shareMindMap'.tr, // 对应中文:分享思维导图
isBorder: false, isBorder: false,
onTap: _controller.meetingDetails.value.tasktype >= 5 onTap: _controller.meetingDetails.value.tasktype >= 5 &&
_controller.meetingDetails.value.tasktype < 10001
? () { ? () {
Get.back(); Get.back();
final mindMapController = final mindMapController =

6
lib/modules/meeting/views/meeting_home_view.dart

@ -267,6 +267,12 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
case 5: case 5:
tasktypeText = 'summaryComplete'.tr; // 对应中文:总结完成 tasktypeText = 'summaryComplete'.tr; // 对应中文:总结完成
break; break;
case 10001:
tasktypeText = '总结失败';
break;
case 10002:
tasktypeText = '转写失败';
break;
default: default:
tasktypeText = ''; tasktypeText = '';
} }

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

@ -708,6 +708,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
await controller.saveTitleChange(); await controller.saveTitleChange();
final saved = await controller.saveRecording(); final saved = await controller.saveRecording();
if (saved) { if (saved) {
_notifyMeetingLocalImport();
Get.back(); Get.back();
} }
}, },
@ -743,8 +744,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
await controller.saveTitleChange(); await controller.saveTitleChange();
final saved = await controller.saveRecording(); final saved = await controller.saveRecording();
if (saved) { if (saved) {
_notifyMeetingLocalImport();
Get.back(); //关闭弹窗 Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面 Get.back(result: 'meeting_record_saved'); //回退到会议记录页面
_deleteControllerAfterPop();
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

54
lib/modules/meeting/views/tabs/overview_tab.dart

@ -99,7 +99,8 @@ class _OverviewTabState extends State<OverviewTab> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (_controller.meetingDetails.value.tasktype >= 3) if (_controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10000)
_button( _button(
Icons.auto_awesome, Icons.auto_awesome,
'Ask AI', 'Ask AI',
@ -138,6 +139,50 @@ class _OverviewTabState extends State<OverviewTab> {
}, },
), ),
), ),
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<OverviewTab> {
Widget _empty() { Widget _empty() {
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Padding( return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 30.w), padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 30.w),
child: Column( child: Column(
children: [ children: [
@ -221,7 +267,9 @@ class _OverviewTabState extends State<OverviewTab> {
), ),
), ),
Text( Text(
'aiAnalysisDesc'.tr, // 对应中文:AI分析识别录音中的关键事件,让你选择重要事件进行AI生成。 _controller.meetingDetails.value.tasktype < 10000
? 'aiAnalysisDesc'.tr // 对应中文:AI分析识别录音中的关键事件,让你选择重要事件进行AI生成。
: '分析失败,请稍后重试',
style: TextStyle( style: TextStyle(
color: MeetingUIUtils.getSecondaryTextColor(isDarkMode), color: MeetingUIUtils.getSecondaryTextColor(isDarkMode),
fontSize: 14.sp, fontSize: 14.sp,

479
lib/modules/meeting/views/tabs/speech_tab.dart

@ -20,229 +20,259 @@ class SpeechTab extends GetView<MeetingDetailsController> {
return Stack( return Stack(
children: [ children: [
Obx( Obx(
() => ListView.builder( () => controller.meetingDetails.value.tasktype != 10002
itemCount: controller.textList.length, ? ListView.builder(
padding: EdgeInsets.only(bottom: 80.w), itemCount: controller.textList.length,
itemBuilder: (BuildContext context, int index) { padding: EdgeInsets.only(bottom: 80.w),
Map item = controller.textList[index]; itemBuilder: (BuildContext context, int index) {
TextStyle textStyle = TextStyle( Map item = controller.textList[index];
fontSize: 15.sp, TextStyle textStyle = TextStyle(
color: MeetingUIUtils.getTextColor(isDarkMode), fontSize: 15.sp,
letterSpacing: 0, color: MeetingUIUtils.getTextColor(isDarkMode),
height: 1.4, letterSpacing: 0,
); height: 1.4,
return Column( );
crossAxisAlignment: CrossAxisAlignment.start, return Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Padding(
padding: EdgeInsets.only(top: 10.w),
child: Row(
children: [ children: [
if (item['speaker'].isNotEmpty) Padding(
Container( padding: EdgeInsets.only(top: 10.w),
width: 11.w, child: Row(
height: 11.w, children: [
margin: EdgeInsets.only(right: 3.w), if (item['speaker'].isNotEmpty)
decoration: BoxDecoration( Container(
borderRadius: BorderRadius.circular(11.r), width: 11.w,
border: Border.all( height: 11.w,
color: const Color(0xFFFFC107), margin: EdgeInsets.only(right: 3.w),
width: 3.w, decoration: BoxDecoration(
), borderRadius: BorderRadius.circular(11.r),
), border: Border.all(
), color: const Color(0xFFFFC107),
if (item['speaker'].isNotEmpty) width: 3.w,
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),
), ),
), ),
); if (item['speaker'].isNotEmpty)
}, GestureDetector(
child: Padding( onTap: () {
padding: EdgeInsets.only(right: 5.w), if (controller.isSpeakerText.value) return;
child: Text( Get.bottomSheet(
item['speaker'], 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( style: TextStyle(
color: const Color(0xFFFFC107), color: MeetingUIUtils.getSecondaryTextColor(
isDarkMode),
fontSize: 13.sp, fontSize: 13.sp,
), ),
), ),
), Obx(() {
), bool isActive = false;
Text( int starttime = item['starttime'];
DateUtil.formatDateMs( int endtime = 0;
item['starttime'], if (index == controller.textList.length - 1) {
format: 'HH:mm:ss', endtime = controller.durationMax.value;
isUtc: true, } else {
), endtime = controller.textList[index + 1]
style: TextStyle( ['starttime'];
color: MeetingUIUtils.getSecondaryTextColor(isDarkMode), }
fontSize: 13.sp, 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(() { Obx(() {
bool isActive = false; if (controller.isSpeakerText.value) {
int starttime = item['starttime']; TextEditingController speakerTextController =
int endtime = 0; TextEditingController(text: item['content']);
if (index == controller.textList.length - 1) { return Obx(
endtime = controller.durationMax.value; () => 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 { } else {
endtime = return Obx(() {
controller.textList[index + 1]['starttime']; bool isActive = false;
} int starttime = item['starttime'];
if (controller.currentDuration.value != 0) { int endtime = 0;
if (controller.currentDuration.value >= starttime && if (index == controller.textList.length - 1) {
controller.currentDuration.value < endtime) { endtime = controller.durationMax.value;
isActive = true; } else {
} endtime =
} controller.textList[index + 1]['starttime'];
return isActive }
? Container( if (controller.currentDuration.value != 0) {
width: 16.w, if (controller.currentDuration.value >=
height: 16.w, starttime &&
margin: EdgeInsets.only(left: 5.w), controller.currentDuration.value <
alignment: Alignment.center, endtime) {
clipBehavior: Clip.hardEdge, isActive = true;
decoration: BoxDecoration( }
color: Colors.red[100], }
borderRadius: BorderRadius.circular(4.r), 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( child: Text(
scale: 1.6, item['content'],
child: Lottie.asset( style: TextStyle(
'assets/lottie/sound_effect.json', fontSize: 15.sp,
controller: controller.lottieController, 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( Obx(
() => Positioned( () => Positioned(
@ -253,7 +283,8 @@ class SpeechTab extends GetView<MeetingDetailsController> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (controller.meetingDetails.value.tasktype >= 2 && if (controller.meetingDetails.value.tasktype >= 2 &&
!controller.isSpeakerText.value) !controller.isSpeakerText.value &&
controller.meetingDetails.value.tasktype != 10002)
_button( _button(
Icons.edit, Icons.edit,
'editSpeaker'.tr, // 对应中文:编辑 'editSpeaker'.tr, // 对应中文:编辑
@ -264,7 +295,8 @@ class SpeechTab extends GetView<MeetingDetailsController> {
}, },
), ),
if (controller.meetingDetails.value.tasktype >= 2 && if (controller.meetingDetails.value.tasktype >= 2 &&
!controller.isSpeakerText.value) !controller.isSpeakerText.value &&
controller.meetingDetails.value.tasktype != 10002)
_button( _button(
Icons.supervisor_account, Icons.supervisor_account,
'nameSpeaker'.tr, // 对应中文:命名发言者 'nameSpeaker'.tr, // 对应中文:命名发言者
@ -272,7 +304,8 @@ class SpeechTab extends GetView<MeetingDetailsController> {
Get.bottomSheet( Get.bottomSheet(
const SpeakerAllBottomSheet(), const SpeakerAllBottomSheet(),
isScrollControlled: true, isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], backgroundColor:
isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r), top: Radius.circular(12.r),
@ -282,7 +315,8 @@ class SpeechTab extends GetView<MeetingDetailsController> {
}, },
), ),
if (controller.meetingDetails.value.tasktype >= 2 && if (controller.meetingDetails.value.tasktype >= 2 &&
!controller.isSpeakerText.value) !controller.isSpeakerText.value &&
controller.meetingDetails.value.tasktype != 10002)
_button( _button(
Icons.auto_awesome, Icons.auto_awesome,
'Ask AI', 'Ask AI',
@ -290,7 +324,8 @@ class SpeechTab extends GetView<MeetingDetailsController> {
Get.bottomSheet( Get.bottomSheet(
const AIBottomSheet(), const AIBottomSheet(),
isScrollControlled: true, isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.white, backgroundColor:
isDarkMode ? Colors.grey[800] : Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r), top: Radius.circular(12.r),
@ -309,7 +344,29 @@ class SpeechTab extends GetView<MeetingDetailsController> {
Get.bottomSheet( Get.bottomSheet(
const GenerateBottomSheet(), const GenerateBottomSheet(),
isScrollControlled: true, 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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r), top: Radius.circular(12.r),

74
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/db/sqflite_api.dart';
import '../../../../data/services/network/api.dart'; import '../../../../data/services/network/api.dart';
import '../../controllers/meeting_details_controller.dart'; import '../../controllers/meeting_details_controller.dart';
import '../../utils/meeting_ui_utils.dart';
import '../bottomSheet/ai_bottom_sheet.dart'; import '../bottomSheet/ai_bottom_sheet.dart';
import '../bottomSheet/generate_bottom_sheet.dart'; import '../bottomSheet/generate_bottom_sheet.dart';
import '../bottomSheet/speaker_all_bottom_sheet.dart'; import '../bottomSheet/speaker_all_bottom_sheet.dart';
@ -335,11 +336,22 @@ class _SummaryTabState extends State<SummaryTab> {
), ),
Padding( Padding(
padding: EdgeInsets.only(top: 10.w), padding: EdgeInsets.only(top: 10.w),
child: FleatherEditor( child: _controller.meetingDetails.value.tasktype < 10000
controller: _fleatherController!, ? FleatherEditor(
focusNode: _focusNode, controller: _fleatherController!,
editorKey: _editorKey, focusNode: _focusNode,
), editorKey: _editorKey,
)
: Center(
child: Text(
'总结失败,请稍后重试',
style: TextStyle(
color: MeetingUIUtils.getSecondaryTextColor(
isDarkMode),
fontSize: 14.sp,
),
),
),
), ),
], ],
), ),
@ -354,7 +366,8 @@ class _SummaryTabState extends State<SummaryTab> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (_controller.meetingDetails.value.tasktype >= 3) if (_controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10000)
_button( _button(
Icons.supervisor_account, Icons.supervisor_account,
'editSpeaker'.tr, // 对应中文:编辑发言者 'editSpeaker'.tr, // 对应中文:编辑发言者
@ -372,7 +385,8 @@ class _SummaryTabState extends State<SummaryTab> {
); );
}, },
), ),
if (_controller.meetingDetails.value.tasktype >= 3) if (_controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10000)
_button( _button(
Icons.auto_awesome, Icons.auto_awesome,
'Ask AI', 'Ask AI',
@ -412,6 +426,52 @@ class _SummaryTabState extends State<SummaryTab> {
}, },
), ),
), ),
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),
),
),
);
},
),
),
], ],
), ),
), ),

2
lib/modules/settings/controllers/settings_controller.dart

@ -404,7 +404,7 @@ class SettingsController extends GetxController {
最近版本检查时间: ${versionService.lastCheckTime ?? '未检查'} 最近版本检查时间: ${versionService.lastCheckTime ?? '未检查'}
国家地区编码: ${versionService.cachedCountryCode} 国家地区编码: ${versionService.cachedCountryCode}
语言编码: ${versionService.cachedLanguageCode} 语言编码: ${versionService.cachedLanguageCode}
服务器地址: ${DioManager().baseUrl} 服务器地址: ${DioManager().dio.options.baseUrl}
'''; ''';
} catch (e) { } catch (e) {
return '获取版本服务数据失败: $e'; return '获取版本服务数据失败: $e';

49
lib/modules/translation/controllers/translation_controller.dart

@ -61,6 +61,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
late final Pip _pip; late final Pip _pip;
late final NativePlugin _nativePlugin; late final NativePlugin _nativePlugin;
int _pipContentView = 0; 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; // 悬浮窗状态 final isFloatingWindowEnabled = false.obs; // 悬浮窗状态
@ -174,7 +178,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
isFloatingWindowEnabled.value = state == PipState.pipStateStarted; isFloatingWindowEnabled.value = state == PipState.pipStateStarted;
if (state == PipState.pipStateFailed) { if (state == PipState.pipStateFailed) {
_isPipExpanded = false;
_pip.dispose(); _pip.dispose();
} else if (state == PipState.pipStateStopped) {
_isPipExpanded = false;
} }
}, },
), ),
@ -190,6 +197,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (Platform.isAndroid) { if (Platform.isAndroid) {
await _disableFloatingWindow(); await _disableFloatingWindow();
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
_isPipExpanded = false;
await _pip.dispose(); await _pip.dispose();
} }
} else { } else {
@ -201,14 +209,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
} }
Future<void> _startPip() async { PipOptions _buildIosPipOptions({required int preferredHeight}) {
if (_pipContentView == 0) { return PipOptions(
_pipContentView = await _nativePlugin.createPipContentView();
}
final options = PipOptions(
autoEnterEnabled: true, autoEnterEnabled: true,
aspectRatioX: 375, aspectRatioX: _pipPreferredWidth,
aspectRatioY: 400, aspectRatioY: preferredHeight,
sourceRectHintLeft: 0, sourceRectHintLeft: 0,
sourceRectHintTop: 0, sourceRectHintTop: 0,
sourceRectHintRight: 0, sourceRectHintRight: 0,
@ -217,12 +222,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
useExternalStateMonitor: true, useExternalStateMonitor: true,
externalStateMonitorInterval: 100, externalStateMonitorInterval: 100,
contentView: _pipContentView, contentView: _pipContentView,
preferredContentWidth: 375, preferredContentWidth: _pipPreferredWidth,
preferredContentHeight: 400, preferredContentHeight: preferredHeight,
controlStyle: 2, controlStyle: 2,
); );
}
await _pip.setup(options); Future<void> _startPip() async {
if (_pipContentView == 0) {
_pipContentView = await _nativePlugin.createPipContentView();
}
_isPipExpanded = false;
await _pip.setup(
_buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight));
await _pip.start(); await _pip.start();
} }
@ -297,6 +309,17 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
); );
await _floatingWindowManager.updateFloatingWindowContent(data); await _floatingWindowManager.updateFloatingWindowContent(data);
} else if (Platform.isIOS) { } 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({ await _nativePlugin.updatePipContentView({
'viewId': _pipContentView, 'viewId': _pipContentView,
'cn': item.sourceText, 'cn': item.sourceText,
@ -315,6 +338,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
loadTranslationHistory(); loadTranslationHistory();
//stopAll(); //stopAll();
if (Platform.isAndroid && (mode == 'call' || mode == 'audioVideo')) {
try {
_floatingWindowManager.setLastTranslationMode(mode);
} catch (_) {}
}
// 检查悬浮窗支持情况 // 检查悬浮窗支持情况
if (isFloatingWindowEnabled.value && !_isFloatingWindowSupportedMode()) { if (isFloatingWindowEnabled.value && !_isFloatingWindowSupportedMode()) {
// 当前模式不支持悬浮窗,自动禁用 // 当前模式不支持悬浮窗,自动禁用

52
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt

@ -80,36 +80,6 @@ class IntegratedSpeechTranslationService(
// 语音映射缓存 // 语音映射缓存
private val voiceCache = ConcurrentHashMap<String, String>() private val voiceCache = ConcurrentHashMap<String, String>()
// 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 { _, _ -> SynthesisStarted.addEventListener { _, _ ->
Log.d(TAG, "合成开始事件") Log.d(TAG, "合成开始事件")
ttsdata = ByteArray(0)
serviceState.isSynthesizing.set(true) serviceState.isSynthesizing.set(true)
eventCallback?.onStateChanged("Synthesis", true) eventCallback?.onStateChanged("Synthesis", true)
} }
@ -437,11 +406,9 @@ class IntegratedSpeechTranslationService(
// 合成进行中事件 // 合成进行中事件
Synthesizing.addEventListener { _, event -> Synthesizing.addEventListener { _, event ->
Log.d(TAG, "合成进行中事件") Log.d(TAG, "合成进行中事件")
// 计算进度(简化版) // 计算进度(简化版)
val progress = 0.5f // 实际应用中可以根据音频数据计算 val progress = 0.5f // 实际应用中可以根据音频数据计算
eventCallback?.onSynthesisProgress("", progress) eventCallback?.onSynthesisProgress("", progress)
val incoming = event.result.audioData ?: ByteArray(0)
processSynthesisAudioChunk("", incoming, 1280)
} }
// 合成完成事件 // 合成完成事件
@ -454,7 +421,6 @@ class IntegratedSpeechTranslationService(
// 合成取消事件 // 合成取消事件
SynthesisCanceled.addEventListener { _, event -> SynthesisCanceled.addEventListener { _, event ->
Log.d(TAG, "合成取消事件") Log.d(TAG, "合成取消事件")
ttsdata = ByteArray(0)
serviceState.isSynthesizing.set(false) serviceState.isSynthesizing.set(false)
val reason = event.result.reason val reason = event.result.reason
eventCallback?.onError("Synthesis", "语音合成取消: $reason") eventCallback?.onError("Synthesis", "语音合成取消: $reason")
@ -639,7 +605,6 @@ class IntegratedSpeechTranslationService(
try { try {
Log.d(TAG, "开始语音合成: $text") Log.d(TAG, "开始语音合成: $text")
serviceState.isSynthesizing.set(true) serviceState.isSynthesizing.set(true)
eventCallback?.onSynthesisStarted(text) eventCallback?.onSynthesisStarted(text)
val ssml = generateOptimizedSsml(text) val ssml = generateOptimizedSsml(text)
@ -653,13 +618,12 @@ class IntegratedSpeechTranslationService(
// 获取音频数据 // 获取音频数据
val audioData = result.audioData val audioData = result.audioData
// if (audioData != null && audioData.isNotEmpty()) { if (audioData != null && audioData.isNotEmpty()) {
// // 通过回调返回音频数据 // 通过回调返回音频数据
// eventCallback?.onSynthesisAudioGenerated(text, audioData) eventCallback?.onSynthesisAudioGenerated(text, audioData)
// Log.d(TAG, "音频数据大小: ${audioData.size} 字节") Log.d(TAG, "音频数据大小: ${audioData.size} 字节")
// } }
eventCallback?.onSynthesisAudioGenerated(text, ttsdata)
ttsdata = ByteArray(0)
return audioData return audioData
} else { } else {
Log.e(TAG, "语音合成失败: ${result?.reason}") Log.e(TAG, "语音合成失败: ${result?.reason}")

8
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) { override fun onSynthesisAudioGenerated(text: String, audioData: ByteArray) {
FileLogger.d(tag, "语音合成音频生成,文本=${text},音频数据大小=${audioData.size}") FileLogger.d(tag, "语音合成音频生成,文本=${text},音频数据大小=${audioData.size}")
// Azure TTS返回的是RIFF WAV格式,需要去除文件头 // Azure TTS返回的是RIFF WAV格式,需要去除文件头
//val pcmData = extractPcmFromWav(audioData) val pcmData = extractPcmFromWav(audioData)
if (audioData.isNotEmpty()) { if (pcmData.isNotEmpty()) {
FileLogger.d(tag, "提取PCM数据,大小=${audioData.size}字节") FileLogger.d(tag, "提取PCM数据,大小=${pcmData.size}字节")
BleService.writeExternalAudioData(audioData) BleService.writeExternalAudioData(pcmData)
} }
else { else {
FileLogger.e(tag, "提取PCM数据失败,音频数据可能不是WAV格式") FileLogger.e(tag, "提取PCM数据失败,音频数据可能不是WAV格式")

8
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) os_log("语音合成音频生成,文本=%@,音频数据大小=%d", log: plugin.log, type: .info, text, audioData.count)
// Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据 // Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据
// let pcmData = plugin.extractPcmFromWav(audioData) let pcmData = plugin.extractPcmFromWav(audioData)
if !audioData.isEmpty { if !pcmData.isEmpty {
os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, audioData.count) os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, pcmData.count)
BleService.shared.writeExternalAudioData(data: audioData) BleService.shared.writeExternalAudioData(data: pcmData)
} else { } else {
os_log("提取PCM数据失败,音频数据可能不是WAV格式", log: plugin.log, type: .error) os_log("提取PCM数据失败,音频数据可能不是WAV格式", log: plugin.log, type: .error)
} }

54
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 let processingQueue = DispatchQueue(label: "com.azure.speech.processing", qos: .userInitiated)
// 合成中的音频缓存
private var ttsBuffer = Data()
// MARK: - Configuration Structures // MARK: - Configuration Structures
/** /**
@ -481,7 +478,6 @@ import os.log
// 合成开始事件 // 合成开始事件
synthesizer?.addSynthesisStartedEventHandler { [weak self] (synthesizer, event) in synthesizer?.addSynthesisStartedEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return } guard let self = self else { return }
self.ttsBuffer = Data()
os_log("合成开始事件", log: self.log, type: .info) os_log("合成开始事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = true self.serviceState.isSynthesizing = true
@ -491,7 +487,7 @@ import os.log
} }
} }
// 合成进行中事件 - 增量发送音频分片(按1280字节对齐) // 合成进行中事件 - 修改为返回音频数据
synthesizer?.addSynthesizingEventHandler { [weak self] (synthesizer, event) in synthesizer?.addSynthesizingEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return } guard let self = self else { return }
// 安全解包audioData // 安全解包audioData
@ -499,8 +495,8 @@ import os.log
os_log("合成进行中事件,音频数据为空", log: self.log, type: .debug) os_log("合成进行中事件,音频数据为空", log: self.log, type: .debug)
return return
} }
self.processSynthesisAudioChunk(text: "", incoming: audioData, multiple: 1280) os_log("合成进行中事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count)
os_log("合成进行中事件,累计缓存长度: %d bytes", log: self.log, type: .debug, self.ttsBuffer.count)
} }
// 合成完成事件 // 合成完成事件
@ -509,14 +505,16 @@ import os.log
os_log("合成完成事件", log: self.log, type: .info) os_log("合成完成事件", log: self.log, type: .info)
// 安全解包audioData // 安全解包audioData
guard let audioData = event.result.audioData else {
// 发送缓存中的剩余数据(可能非1280对齐的余量) os_log("合成完成事件,音频数据为空", log: self.log, type: .debug)
let remaining = self.ttsBuffer return
self.ttsBuffer = Data() }
os_log("合成完成事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count)
DispatchQueue.main.async { DispatchQueue.main.async {
self.serviceState.isSynthesizing = false self.serviceState.isSynthesizing = false
self.eventCallback?.onSynthesisAudioGenerated(text: "", audioData: remaining) // 立即返回当前生成的音频数据
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: 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) os_log("合成取消事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = false self.serviceState.isSynthesizing = false
// 取消时清空缓存,避免脏数据
self.ttsBuffer = Data()
// 修复:移除可选链,因为event.result不是可选类型 // 修复:移除可选链,因为event.result不是可选类型
let reason = event.result.reason 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 // MARK: - Public Methods
/** /**

0
local_plugins/native_plugin/.gitignore → local_plugins/floating_ui_plugin/.gitignore

0
local_plugins/native_plugin/.metadata → local_plugins/floating_ui_plugin/.metadata

0
local_plugins/native_plugin/CHANGELOG.md → local_plugins/floating_ui_plugin/CHANGELOG.md

0
local_plugins/native_plugin/LICENSE → local_plugins/floating_ui_plugin/LICENSE

120
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 声明问题。

0
local_plugins/native_plugin/analysis_options.yaml → local_plugins/floating_ui_plugin/analysis_options.yaml

0
local_plugins/native_plugin/android/.gitignore → local_plugins/floating_ui_plugin/android/.gitignore

0
local_plugins/native_plugin/android/build.gradle → local_plugins/floating_ui_plugin/android/build.gradle

1
local_plugins/floating_ui_plugin/android/settings.gradle

@ -0,0 +1 @@
rootProject.name = 'floating_ui_plugin'

0
local_plugins/native_plugin/android/src/main/AndroidManifest.xml → local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml

21
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt → local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt

@ -459,6 +459,11 @@ class FloatingWindowService : Service() {
stopSelf() stopSelf()
notifyClosed() notifyClosed()
} }
languageInfoView?.setOnClickListener {
bringAppToFront()
notifyClicked()
}
} }
private fun setupDragListener() { private fun setupDragListener() {
@ -492,6 +497,9 @@ class FloatingWindowService : Service() {
if (!isMinimized && isTouchingView(resizeHandleView, event.rawX, event.rawY)) { if (!isMinimized && isTouchingView(resizeHandleView, event.rawX, event.rawY)) {
return false return false
} }
if (!isMinimized && isTouchingView(languageInfoView, event.rawX, event.rawY)) {
return false
}
dragging = false dragging = false
scaling = false scaling = false
@ -684,6 +692,19 @@ class FloatingWindowService : Service() {
channel.invokeMethod("onFloatingWindowClicked", null) 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 { private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt() return (value * resources.displayMetrics.density).toInt()
} }

0
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt → local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt

0
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt → local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

0
local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java → local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java

0
local_plugins/native_plugin/ios/.gitignore → local_plugins/floating_ui_plugin/ios/.gitignore

0
local_plugins/native_plugin/ios/Assets/.gitkeep → local_plugins/floating_ui_plugin/ios/Assets/.gitkeep

0
local_plugins/native_plugin/ios/Classes/FLNativeView.swift → local_plugins/floating_ui_plugin/ios/Classes/FLNativeView.swift

0
local_plugins/native_plugin/ios/Classes/NativePlugin.swift → local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift

220
local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift

@ -0,0 +1,220 @@
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
private var isExpanded: Bool = false
private let compactPreferredHeight: CGFloat = 72
private let expandedPreferredHeight: CGFloat = 220
public override init(frame: CGRect) {
stackView = UIStackView()
super.init(frame: frame)
setupUI()
}
public convenience init() {
self.init(frame: .zero)
}
required init?(coder: NSCoder) {
stackView = UIStackView()
super.init(coder: coder)
setupUI()
}
private func setupUI() {
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 = UIColor(white: 1.0, alpha: 235.0 / 255.0)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
titleLabel.isHidden = true
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: 14)
cnLabel.textColor = UIColor(white: 1.0, alpha: 235.0 / 255.0)
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
para.lineHeightMultiple = 1.1
cnLabel.attributedText = NSAttributedString(string: "", attributes: [.paragraphStyle: para])
enLabel.attributedText = NSAttributedString(string: "", attributes: [.paragraphStyle: para])
stackView.axis = .vertical
stackView.spacing = 10
stackView.alignment = .fill
stackView.distribution = .fill
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(titleLabel)
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)
layoutMargins = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)
addSubview(stackView)
let guide = layoutMarginsGuide
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: guide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor),
])
}
public func play() {
}
public func pause() {
}
public func update(withLang lang: String?, cn: String?, en: String?) {
if let lang = lang {
langLabel.text = lang
}
let para = NSMutableParagraphStyle()
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])
}
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 {
lastBoundsSize = bounds.size
lastLangText = currentLangText
updateDynamicTextLineLimit()
}
}
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 }
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), 10))
if perLabelLines != lastDynamicTextLines {
lastDynamicTextLines = perLabelLines
cnLabel.numberOfLines = perLabelLines
enLabel.numberOfLines = perLabelLines
}
}
}

0
local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy → local_plugins/floating_ui_plugin/ios/Resources/PrivacyInfo.xcprivacy

0
local_plugins/native_plugin/ios/floating_ui_plugin.podspec → local_plugins/floating_ui_plugin/ios/floating_ui_plugin.podspec

0
local_plugins/native_plugin/lib/native_plugin.dart → local_plugins/floating_ui_plugin/lib/native_plugin.dart

0
local_plugins/native_plugin/lib/native_plugin_method_channel.dart → local_plugins/floating_ui_plugin/lib/native_plugin_method_channel.dart

0
local_plugins/native_plugin/lib/native_plugin_platform_interface.dart → local_plugins/floating_ui_plugin/lib/native_plugin_platform_interface.dart

0
local_plugins/native_plugin/pubspec.yaml → local_plugins/floating_ui_plugin/pubspec.yaml

15
local_plugins/native_plugin/README.md

@ -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.

1
local_plugins/native_plugin/android/settings.gradle

@ -1 +0,0 @@
rootProject.name = 'native_plugin'

98
local_plugins/native_plugin/ios/Classes/PlayerView.swift

@ -1,98 +0,0 @@
import UIKit
public class PlayerView: UIView {
private let titleLabel: UILabel = UILabel()
private let langLabel: UILabel = UILabel()
private let cnLabel: UILabel = UILabel()
private let enLabel: UILabel = UILabel()
private let stackView: UIStackView
public override init(frame: CGRect) {
stackView = UIStackView()
super.init(frame: frame)
setupUI()
}
public convenience init() {
self.init(frame: .zero)
}
required init?(coder: NSCoder) {
stackView = UIStackView()
super.init(coder: coder)
setupUI()
}
private func setupUI() {
backgroundColor = .white
titleLabel.text = "实时翻译"
titleLabel.font = .boldSystemFont(ofSize: 22)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
langLabel.text = "中文(简体) -> 英语"
langLabel.font = .systemFont(ofSize: 14)
langLabel.textColor = .darkGray
langLabel.textAlignment = .left
langLabel.numberOfLines = 1
cnLabel.font = .systemFont(ofSize: 18)
cnLabel.textColor = .black
cnLabel.textAlignment = .left
cnLabel.numberOfLines = 0
enLabel.font = .systemFont(ofSize: 18)
enLabel.textColor = UIColor(red: 0.24, green: 0.54, blue: 0.90, alpha: 1.0)
enLabel.textAlignment = .left
enLabel.numberOfLines = 0
let para = NSMutableParagraphStyle()
para.lineSpacing = 4
cnLabel.attributedText = NSAttributedString(string: "正在识别...", attributes: [.paragraphStyle: para])
enLabel.attributedText = NSAttributedString(string: "翻译中...", attributes: [.paragraphStyle: para])
stackView.axis = .vertical
stackView.spacing = 12
stackView.alignment = .fill
stackView.distribution = .fill
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(langLabel)
stackView.addArrangedSubview(cnLabel)
stackView.addArrangedSubview(enLabel)
layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
addSubview(stackView)
let guide = layoutMarginsGuide
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: guide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor),
])
}
public func play() {
}
public func pause() {
}
public func update(withLang lang: String?, cn: String?, en: String?) {
if let lang = lang {
langLabel.text = lang
}
let para = NSMutableParagraphStyle()
para.lineSpacing = 4
if let cn = cn {
cnLabel.attributedText = NSAttributedString(string: cn, attributes: [.paragraphStyle: para])
}
if let en = en {
enLabel.attributedText = NSAttributedString(string: en, attributes: [.paragraphStyle: para])
}
}
}

2
pubspec.yaml

@ -117,7 +117,7 @@ dependencies:
qq_music: qq_music:
path: local_plugins/qq_music path: local_plugins/qq_music
floating_ui_plugin: floating_ui_plugin:
path: local_plugins/native_plugin path: local_plugins/floating_ui_plugin
test: test:
path: local_plugins/test path: local_plugins/test

Loading…
Cancel
Save