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