53 changed files with 1349 additions and 1119 deletions
@ -1,133 +0,0 @@ |
|||||
package com.saitong.voitrans |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.content.Intent |
|
||||
import android.net.Uri |
|
||||
import android.os.Build |
|
||||
import android.provider.Settings |
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|
||||
import io.flutter.plugin.common.MethodCall |
|
||||
import io.flutter.plugin.common.MethodChannel |
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|
||||
import io.flutter.plugin.common.MethodChannel.Result |
|
||||
|
|
||||
class FloatingWindowPlugin: FlutterPlugin, MethodCallHandler { |
|
||||
private lateinit var channel: MethodChannel |
|
||||
private lateinit var context: Context |
|
||||
|
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|
||||
context = flutterPluginBinding.applicationContext |
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "floating_window") |
|
||||
channel.setMethodCallHandler(this) |
|
||||
|
|
||||
// 设置回调通道 |
|
||||
FloatingWindowService.methodChannel = channel |
|
||||
} |
|
||||
|
|
||||
override fun onMethodCall(call: MethodCall, result: Result) { |
|
||||
when (call.method) { |
|
||||
"enableFloatingWindow" -> { |
|
||||
enableFloatingWindow(result) |
|
||||
} |
|
||||
"disableFloatingWindow" -> { |
|
||||
disableFloatingWindow(result) |
|
||||
} |
|
||||
"showFloatingWindow" -> { |
|
||||
showFloatingWindow(result) |
|
||||
} |
|
||||
"hideFloatingWindow" -> { |
|
||||
hideFloatingWindow(result) |
|
||||
} |
|
||||
"updateContent" -> { |
|
||||
updateContent(call, result) |
|
||||
} |
|
||||
"setPosition" -> { |
|
||||
setPosition(call, result) |
|
||||
} |
|
||||
else -> { |
|
||||
result.notImplemented() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun enableFloatingWindow(result: Result) { |
|
||||
try { |
|
||||
val intent = Intent(context, FloatingWindowService::class.java) |
|
||||
context.startService(intent) |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("ENABLE_ERROR", "Failed to enable floating window: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun disableFloatingWindow(result: Result) { |
|
||||
try { |
|
||||
val intent = Intent(context, FloatingWindowService::class.java) |
|
||||
context.stopService(intent) |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("DISABLE_ERROR", "Failed to disable floating window: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun showFloatingWindow(result: Result) { |
|
||||
try { |
|
||||
// 服务已经在运行,不需要额外操作 |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("SHOW_ERROR", "Failed to show floating window: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun hideFloatingWindow(result: Result) { |
|
||||
try { |
|
||||
FloatingWindowService.serviceInstance?.stopSelf() |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("HIDE_ERROR", "Failed to hide floating window: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun updateContent(call: MethodCall, result: Result) { |
|
||||
try { |
|
||||
val args = call.arguments as? Map<String, Any> |
|
||||
if (args != null) { |
|
||||
val intent = Intent(context, FloatingWindowService::class.java).apply { |
|
||||
action = FloatingWindowService.ACTION_UPDATE_CONTENT |
|
||||
putExtra(FloatingWindowService.EXTRA_SOURCE_TEXT, args["sourceText"] as? String ?: "") |
|
||||
putExtra(FloatingWindowService.EXTRA_TRANSLATED_TEXT, args["translatedText"] as? String ?: "") |
|
||||
putExtra(FloatingWindowService.EXTRA_SOURCE_LANGUAGE, args["sourceLanguage"] as? String ?: "") |
|
||||
putExtra(FloatingWindowService.EXTRA_TARGET_LANGUAGE, args["targetLanguage"] as? String ?: "") |
|
||||
putExtra(FloatingWindowService.EXTRA_TIMESTAMP, (args["timestamp"] as? Number)?.toLong() ?: 0L) |
|
||||
putExtra(FloatingWindowService.EXTRA_IS_INTERMEDIATE, args["isIntermediate"] as? Boolean ?: false) |
|
||||
} |
|
||||
context.startService(intent) |
|
||||
} |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("UPDATE_ERROR", "Failed to update content: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun setPosition(call: MethodCall, result: Result) { |
|
||||
try { |
|
||||
val args = call.arguments as? Map<String, Any> |
|
||||
if (args != null) { |
|
||||
val intent = Intent(context, FloatingWindowService::class.java).apply { |
|
||||
action = FloatingWindowService.ACTION_SET_POSITION |
|
||||
putExtra(FloatingWindowService.EXTRA_X, (args["x"] as? Number)?.toInt() ?: 0) |
|
||||
putExtra(FloatingWindowService.EXTRA_Y, (args["y"] as? Number)?.toInt() ?: 0) |
|
||||
} |
|
||||
context.startService(intent) |
|
||||
} |
|
||||
result.success(true) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("POSITION_ERROR", "Failed to set position: ${e.message}", null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|
||||
channel.setMethodCallHandler(null) |
|
||||
FloatingWindowService.methodChannel = null |
|
||||
} |
|
||||
} |
|
||||
@ -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() |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,120 @@ |
|||||
|
# floating_ui_plugin |
||||
|
|
||||
|
该目录是一个本地 Flutter 插件包,当前在项目中主要承担「悬浮 UI」相关的原生能力: |
||||
|
|
||||
|
- Android:翻译悬浮窗(系统悬浮窗 / overlay window)+ 录音悬浮条(计时/暂停/关闭)+ 悬浮窗权限检查/跳转。 |
||||
|
- iOS:为系统画中画(PiP)提供可渲染的原生内容 View,并支持动态更新翻译文本。 |
||||
|
|
||||
|
> 说明:Flutter 包名为 floating_ui_plugin(见本插件 pubspec.yaml),主工程通过 path 依赖引用本目录。 |
||||
|
|
||||
|
## 主要能力 |
||||
|
|
||||
|
### 1) Android 翻译悬浮窗(Translation Floating Window) |
||||
|
|
||||
|
- 通道:`MethodChannel('floating_window')` |
||||
|
- 原理:Flutter 调用通道方法 → Android 启动/更新 `FloatingWindowService` → service 通过系统 WindowManager 显示 overlay view。 |
||||
|
- 交互回传:service 会反向调用同一通道,把状态/点击/关闭事件回传给 Flutter。 |
||||
|
|
||||
|
支持的方法(Flutter → Android): |
||||
|
- `enableFloatingWindow`:启动悬浮窗服务(如果已在运行,相当于确保存在)。 |
||||
|
- `disableFloatingWindow`:停止悬浮窗服务。 |
||||
|
- `showFloatingWindow`:显示悬浮窗(当前实现等价于启动服务)。 |
||||
|
- `hideFloatingWindow`:隐藏悬浮窗(stopSelf)。 |
||||
|
- `updateContent(Map)`:更新展示内容。 |
||||
|
- `setPosition({x,y})`:设置悬浮窗位置。 |
||||
|
|
||||
|
回传的方法(Android → Flutter): |
||||
|
- `onFloatingWindowStateChanged({state})`:`hidden|minimized|expanded` |
||||
|
- `onFloatingWindowClosed()`:用户关闭。 |
||||
|
- `onFloatingWindowClicked()`:用户点击语言区域(service 内部会 bringAppToFront)。 |
||||
|
|
||||
|
数据协议(updateContent): |
||||
|
- `sourceText` / `translatedText` |
||||
|
- `sourceLanguage` / `targetLanguage` |
||||
|
- `timestamp`(毫秒时间戳) |
||||
|
- `isIntermediate`(是否中间态) |
||||
|
|
||||
|
对应实现: |
||||
|
- Android 插件入口:[NativePlugin.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt) |
||||
|
- 悬浮窗服务:[FloatingWindowService.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt) |
||||
|
|
||||
|
### 2) Android 悬浮窗权限(Overlay Permission) |
||||
|
|
||||
|
- 通道:`MethodChannel('overlay_permission')` |
||||
|
- 方法: |
||||
|
- `hasOverlayPermission`:是否具备 SYSTEM_ALERT_WINDOW |
||||
|
- `requestOverlayPermission`:跳转系统页面申请 |
||||
|
|
||||
|
对应实现:[NativePlugin.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt) |
||||
|
|
||||
|
### 3) Android 录音悬浮条(Recording Floating Bar) |
||||
|
|
||||
|
- 通道:`MethodChannel('native_plugin')` |
||||
|
- 原理:Flutter 下发 show/update/hide → Android 启动/更新 `RecordingFloatingService` → service 以 overlay 方式显示一个小型悬浮条。 |
||||
|
|
||||
|
支持的方法(Flutter → Android): |
||||
|
- `showRecordingFloatingBar({duration, recordingType, isPaused})` |
||||
|
- `updateRecordingFloatingBar({duration, recordingType, isPaused})` |
||||
|
- `hideRecordingFloatingBar()` |
||||
|
|
||||
|
用户操作回传(Android → Flutter): |
||||
|
- 事件通道:`EventChannel('native_plugin/recording_floating_actions')` |
||||
|
- action 字符串: |
||||
|
- `togglePause`:点击暂停/继续 |
||||
|
- `openMeetingRecord`:点击中间区域,拉起 App 并打开录音页 |
||||
|
- `closeFloatingBar`:关闭悬浮条 |
||||
|
|
||||
|
对应实现: |
||||
|
- 服务:[RecordingFloatingService.kt](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt) |
||||
|
- 事件桥接:[NativePlugin.emitRecordingFloatingAction](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt#L26-L39) |
||||
|
|
||||
|
## iOS:PiP 内容 View(Picture-in-Picture Content View) |
||||
|
|
||||
|
- 通道:`MethodChannel('native_plugin')` |
||||
|
- 目标:创建一个用于 PiP 渲染的原生 `UIView`(`PlayerView`),并支持后续更新显示内容。 |
||||
|
|
||||
|
支持的方法: |
||||
|
- `createPipContentView()`:返回一个 `viewId`(本质是 PlayerView 的指针值)。 |
||||
|
- `updatePipContentView({viewId?, lang, cn, en})`:更新内容。 |
||||
|
- `disposePipContentView(viewId)`:释放。 |
||||
|
|
||||
|
对应实现: |
||||
|
- 插件入口:[NativePlugin.swift](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift) |
||||
|
- 具体 UI:[PlayerView.swift](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift) |
||||
|
|
||||
|
## 在主工程中的调用链路(现状) |
||||
|
|
||||
|
### Android:翻译悬浮窗 |
||||
|
|
||||
|
- 入口:翻译页控制器 [TranslationController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/translation/controllers/translation_controller.dart) |
||||
|
- `toggleFloatingWindow()` → `_enableFloatingWindow()` |
||||
|
- 管理器:[FloatingWindowManager](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/core/services/floating_window_manager.dart) |
||||
|
- `enableFloatingWindow()`:先通过 [OverlayPermissionUtil](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/core/utils/overlay_permission_util.dart) 请求权限,再调用 `TranslationFloatingWindowApi.enable()` |
||||
|
- `_setupMethodCallHandler()`:接收 `onFloatingWindowStateChanged/onFloatingWindowClicked/onFloatingWindowClosed` 并更新状态/路由跳转 |
||||
|
- 内容更新:`TranslationController._updateFloatingWindowWithTranslation()` → `FloatingWindowManager.updateFloatingWindowContent()` → `updateContent(Map)` |
||||
|
|
||||
|
### Android:录音悬浮条 |
||||
|
|
||||
|
- 入口:会议录音控制器 [MeetingRecordController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/meeting/controllers/meeting_record_controller.dart) |
||||
|
- `showBackgroundRecordingIndicator()` → `_startAndroidRecordingFloatingBar()` → `NativePlugin.showRecordingFloatingBar()` |
||||
|
- 定时器每秒 `updateRecordingFloatingBar()` 更新 duration/isPaused |
||||
|
- `_setupRecordingFloatingBarActionListener()` 监听 `recordingFloatingActionStream` 并处理 `togglePause/openMeetingRecord/closeFloatingBar` |
||||
|
|
||||
|
### iOS:翻译悬浮显示(PiP) |
||||
|
|
||||
|
- 入口同样在 [TranslationController](file:///Users/liuxiamin/work/flutter_projects/deep_voice/lib/modules/translation/controllers/translation_controller.dart) |
||||
|
- `_startPip()`:`NativePlugin.createPipContentView()` → 将 viewId 传给第三方 PiP 组件的 `PipOptions.contentView` |
||||
|
- `_updateFloatingWindowWithTranslation()`:`NativePlugin.updatePipContentView({viewId, cn, en, lang})` |
||||
|
|
||||
|
## 权限与 Manifest |
||||
|
|
||||
|
- Android 需要:`android.permission.SYSTEM_ALERT_WINDOW` |
||||
|
- 本插件自带 AndroidManifest 声明(包含两项 service): |
||||
|
- [android/src/main/AndroidManifest.xml](file:///Users/liuxiamin/work/flutter_projects/deep_voice/local_plugins/floating_ui_plugin/android/src/main/AndroidManifest.xml) |
||||
|
|
||||
|
## 平台差异与注意事项 |
||||
|
|
||||
|
- Android 与 iOS 的“悬浮显示”实现不同:Android 用系统悬浮窗;iOS 走系统 PiP。 |
||||
|
- `createPipContentView/updatePipContentView/disposePipContentView` 仅 iOS 实现;Android 侧没有实现这些 method,请务必用 `Platform.isIOS` 保护调用。 |
||||
|
- `floating_window` / `overlay_permission` / `native_plugin/recording_floating_actions` 都是固定通道名,避免在其他地方重复注册同名通道。 |
||||
|
- 工程 `android/app/src/main/kotlin/com/saitong/voitrans/` 下存在同名通道的旧实现文件(未在 MainActivity 注册),当前实际走的是本插件实现;如后续要启用 app 侧实现,需要先解决通道名冲突与 service 声明问题。 |
||||
@ -0,0 +1 @@ |
|||||
|
rootProject.name = 'floating_ui_plugin' |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,15 +0,0 @@ |
|||||
# native_plugin |
|
||||
|
|
||||
A new Flutter plugin project. |
|
||||
|
|
||||
## Getting Started |
|
||||
|
|
||||
This project is a starting point for a Flutter |
|
||||
[plug-in package](https://flutter.dev/to/develop-plugins), |
|
||||
a specialized package that includes platform-specific implementation code for |
|
||||
Android and/or iOS. |
|
||||
|
|
||||
For help getting started with Flutter development, view the |
|
||||
[online documentation](https://docs.flutter.dev), which offers tutorials, |
|
||||
samples, guidance on mobile development, and a full API reference. |
|
||||
|
|
||||
@ -1 +0,0 @@ |
|||||
rootProject.name = 'native_plugin' |
|
||||
@ -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]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Loading…
Reference in new issue