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