From 02cd3195573b0b1f56ec6edf14f216cf75769d62 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 12 Jan 2026 17:56:03 +0800 Subject: [PATCH] =?UTF-8?q?refactor(native=5Fplugin):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?iOS=E5=92=8CAndroid=E5=8E=9F=E7=94=9F=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将iOS原生代码从Objective-C迁移到Swift,并重构Android代码为Kotlin 更新iOS插件配置和依赖,添加Swift版本支持 优化Android构建配置,添加Kotlin支持 实现新的浮动窗口服务和录音悬浮条功能 --- .../native_plugin/android/build.gradle | 8 + .../native_plugin/FloatingWindowService.java | 405 ------------------ .../example/native_plugin/NativePlugin.java | 314 -------------- .../RecordingFloatingService.java | 292 ------------- .../native_plugin/FloatingWindowService.kt | 403 +++++++++++++++++ .../com/example/native_plugin/NativePlugin.kt | 254 +++++++++++ .../native_plugin/RecordingFloatingService.kt | 297 +++++++++++++ .../native_plugin/ios/Classes/FLNativeView.h | 15 - .../native_plugin/ios/Classes/FLNativeView.m | 83 ---- .../ios/Classes/FLNativeView.swift | 70 +++ .../native_plugin/ios/Classes/NativePlugin.h | 4 - .../native_plugin/ios/Classes/NativePlugin.m | 114 ----- .../ios/Classes/NativePlugin.swift | 95 ++++ .../native_plugin/ios/Classes/PlayerView.h | 15 - .../native_plugin/ios/Classes/PlayerView.m | 100 ----- .../ios/Classes/PlayerView.swift | 98 +++++ .../ios/floating_ui_plugin.podspec | 3 +- local_plugins/native_plugin/pubspec.yaml | 2 +- 18 files changed, 1227 insertions(+), 1345 deletions(-) delete mode 100644 local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java delete mode 100644 local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java delete mode 100644 local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java create mode 100644 local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt create mode 100644 local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt create mode 100644 local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt delete mode 100644 local_plugins/native_plugin/ios/Classes/FLNativeView.h delete mode 100644 local_plugins/native_plugin/ios/Classes/FLNativeView.m create mode 100644 local_plugins/native_plugin/ios/Classes/FLNativeView.swift delete mode 100644 local_plugins/native_plugin/ios/Classes/NativePlugin.h delete mode 100644 local_plugins/native_plugin/ios/Classes/NativePlugin.m create mode 100644 local_plugins/native_plugin/ios/Classes/NativePlugin.swift delete mode 100644 local_plugins/native_plugin/ios/Classes/PlayerView.h delete mode 100644 local_plugins/native_plugin/ios/Classes/PlayerView.m create mode 100644 local_plugins/native_plugin/ios/Classes/PlayerView.swift diff --git a/local_plugins/native_plugin/android/build.gradle b/local_plugins/native_plugin/android/build.gradle index 948c5ef6f..bab3210a9 100644 --- a/local_plugins/native_plugin/android/build.gradle +++ b/local_plugins/native_plugin/android/build.gradle @@ -2,6 +2,7 @@ group = "com.example.native_plugin" version = "1.0" buildscript { + ext.kotlin_version = "1.7.20" repositories { google() mavenCentral() @@ -9,6 +10,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:8.7.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") } } @@ -20,6 +22,7 @@ rootProject.allprojects { } apply plugin: "com.android.library" +apply plugin: "kotlin-android" android { namespace = "com.example.native_plugin" @@ -31,11 +34,16 @@ android { targetCompatibility = JavaVersion.VERSION_11 } + kotlinOptions { + jvmTarget = "11" + } + defaultConfig { minSdk = 21 } dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") testImplementation("junit:junit:4.13.2") testImplementation("org.mockito:mockito-core:5.0.0") } diff --git a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java deleted file mode 100644 index 8549a7849..000000000 --- a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java +++ /dev/null @@ -1,405 +0,0 @@ -package com.example.native_plugin; - -import android.app.Service; -import android.content.Intent; -import android.graphics.Color; -import android.graphics.PixelFormat; -import android.graphics.Typeface; -import android.graphics.drawable.GradientDrawable; -import android.os.Build; -import android.os.IBinder; -import android.text.TextUtils; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -import io.flutter.plugin.common.MethodChannel; - -public class FloatingWindowService extends Service { - static final String ACTION_UPDATE_CONTENT = "UPDATE_CONTENT"; - static final String ACTION_SET_POSITION = "SET_POSITION"; - - static final String EXTRA_SOURCE_TEXT = "source_text"; - static final String EXTRA_TRANSLATED_TEXT = "translated_text"; - static final String EXTRA_SOURCE_LANGUAGE = "source_language"; - static final String EXTRA_TARGET_LANGUAGE = "target_language"; - static final String EXTRA_TIMESTAMP = "timestamp"; - static final String EXTRA_IS_INTERMEDIATE = "is_intermediate"; - static final String EXTRA_X = "x"; - static final String EXTRA_Y = "y"; - - static volatile MethodChannel methodChannel; - static volatile FloatingWindowService serviceInstance; - - private WindowManager windowManager; - private View floatingView; - private WindowManager.LayoutParams layoutParams; - private boolean isMinimized = true; - - private TextView sourceTextView; - private TextView translatedTextView; - private TextView languageInfoView; - private TextView timestampView; - private View minimizedView; - private View expandedView; - private TextView closeButton; - - @Override - public void onCreate() { - super.onCreate(); - serviceInstance = this; - createFloatingWindow(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null) { - String action = intent.getAction(); - if (ACTION_UPDATE_CONTENT.equals(action)) { - updateContent(intent); - } else if (ACTION_SET_POSITION.equals(action)) { - setPosition(intent); - } - } - return START_STICKY; - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public void onDestroy() { - removeFloatingWindow(); - serviceInstance = null; - super.onDestroy(); - } - - private void createFloatingWindow() { - windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); - if (windowManager == null) return; - - floatingView = createFloatingLayout(); - initViews(); - setupClickListeners(); - setupDragListener(); - - layoutParams = new WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - : WindowManager.LayoutParams.TYPE_PHONE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN - | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, - PixelFormat.TRANSLUCENT - ); - layoutParams.gravity = Gravity.TOP | Gravity.START; - layoutParams.x = dp(100); - layoutParams.y = dp(100); - - windowManager.addView(floatingView, layoutParams); - updateViewState(true); - } - - private View createFloatingLayout() { - FrameLayout root = new FrameLayout(this); - root.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - )); - - minimizedView = createMinimizedView(); - expandedView = createExpandedView(); - - root.addView(minimizedView); - root.addView(expandedView); - return root; - } - - private View createMinimizedView() { - FrameLayout container = new FrameLayout(this); - FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(dp(60), dp(60)); - int margin = dp(8); - lp.setMargins(margin, margin, margin, margin); - container.setLayoutParams(lp); - - GradientDrawable bg = new GradientDrawable(); - bg.setColor(0xFFE3F2FD); - bg.setCornerRadius(dp(30)); - container.setBackground(bg); - container.setElevation(dp(8)); - - TextView icon = new TextView(this); - icon.setText("译"); - icon.setTextColor(0xFF1976D2); - icon.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - icon.setGravity(Gravity.CENTER); - icon.setTypeface(Typeface.DEFAULT_BOLD); - container.addView(icon, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - return container; - } - - private View createExpandedView() { - LinearLayout container = new LinearLayout(this); - FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT); - int margin = dp(8); - lp.setMargins(margin, margin, margin, margin); - container.setLayoutParams(lp); - container.setOrientation(LinearLayout.VERTICAL); - container.setPadding(dp(16), dp(16), dp(16), dp(16)); - container.setVisibility(View.GONE); - container.setElevation(dp(12)); - - GradientDrawable bg = new GradientDrawable(); - bg.setColor(Color.WHITE); - bg.setCornerRadius(dp(16)); - container.setBackground(bg); - - LinearLayout titleRow = new LinearLayout(this); - titleRow.setOrientation(LinearLayout.HORIZONTAL); - titleRow.setGravity(Gravity.CENTER_VERTICAL); - titleRow.setLayoutParams(new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - )); - - TextView title = new TextView(this); - title.setText("实时翻译"); - title.setTextColor(0xFF333333); - title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); - title.setTypeface(Typeface.DEFAULT_BOLD); - LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); - title.setLayoutParams(titleLp); - - closeButton = new TextView(this); - closeButton.setText("✕"); - closeButton.setTextColor(0xFF666666); - closeButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); - closeButton.setGravity(Gravity.CENTER); - closeButton.setPadding(dp(8), dp(8), dp(8), dp(8)); - - titleRow.addView(title); - titleRow.addView(closeButton); - - languageInfoView = new TextView(this); - languageInfoView.setText("中文 → 英文"); - languageInfoView.setTextColor(0xFF666666); - languageInfoView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); - languageInfoView.setPadding(0, dp(8), 0, dp(8)); - - sourceTextView = new TextView(this); - sourceTextView.setText("正在识别..."); - sourceTextView.setTextColor(0xFF333333); - sourceTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - sourceTextView.setPadding(0, dp(8), 0, dp(4)); - sourceTextView.setMaxLines(3); - sourceTextView.setEllipsize(TextUtils.TruncateAt.END); - - translatedTextView = new TextView(this); - translatedTextView.setText("翻译中..."); - translatedTextView.setTextColor(0xFF1976D2); - translatedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - translatedTextView.setPadding(0, dp(4), 0, dp(8)); - translatedTextView.setMaxLines(3); - translatedTextView.setEllipsize(TextUtils.TruncateAt.END); - - timestampView = new TextView(this); - timestampView.setText(""); - timestampView.setTextColor(0xFF999999); - timestampView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10); - timestampView.setGravity(Gravity.END); - - container.addView(titleRow); - container.addView(languageInfoView); - container.addView(sourceTextView); - container.addView(translatedTextView); - container.addView(timestampView); - - return container; - } - - private void initViews() { - if (!(floatingView instanceof ViewGroup)) return; - // Views already created and stored in fields - } - - private void setupClickListeners() { - if (floatingView != null) { - floatingView.setOnLongClickListener(v -> { - stopSelf(); - notifyClosed(); - return true; - }); - } - if (closeButton != null) { - closeButton.setOnClickListener(v -> { - stopSelf(); - notifyClosed(); - }); - } - } - - private void setupDragListener() { - if (floatingView == null) return; - final int threshold = dp(4); - floatingView.setOnTouchListener(new View.OnTouchListener() { - private int initialX; - private int initialY; - private float initialTouchX; - private float initialTouchY; - private boolean dragging; - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (layoutParams == null || windowManager == null) return false; - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - initialX = layoutParams.x; - initialY = layoutParams.y; - initialTouchX = event.getRawX(); - initialTouchY = event.getRawY(); - dragging = false; - return true; - case MotionEvent.ACTION_MOVE: { - int dx = (int) (event.getRawX() - initialTouchX); - int dy = (int) (event.getRawY() - initialTouchY); - if (!dragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) { - dragging = true; - } - final int maxX = getResources().getDisplayMetrics().widthPixels - (floatingView == null ? 0 : floatingView.getWidth()); - final int maxY = getResources().getDisplayMetrics().heightPixels - (floatingView == null ? 0 : floatingView.getHeight()); - int nextX = initialX + dx; - int nextY = initialY + dy; - if (maxX > 0) nextX = Math.max(0, Math.min(nextX, maxX)); - if (maxY > 0) nextY = Math.max(0, Math.min(nextY, maxY)); - layoutParams.x = nextX; - layoutParams.y = nextY; - windowManager.updateViewLayout(floatingView, layoutParams); - return true; - } - case MotionEvent.ACTION_UP: - if (!dragging) { - updateViewState(!isMinimized); - } - dragging = false; - return true; - default: - return false; - } - } - }); - } - - private void updateViewState(boolean minimized) { - isMinimized = minimized; - if (minimizedView != null) minimizedView.setVisibility(minimized ? View.VISIBLE : View.GONE); - if (expandedView != null) expandedView.setVisibility(minimized ? View.GONE : View.VISIBLE); - notifyStateChanged(minimized ? "minimized" : "expanded"); - } - - private void updateContent(Intent intent) { - String sourceText = intent.getStringExtra(EXTRA_SOURCE_TEXT); - String translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT); - String sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE); - String targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE); - long timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L); - boolean isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false); - - if (languageInfoView != null) { - String langText = (sourceLang == null ? "" : sourceLang) + " → " + (targetLang == null ? "" : targetLang); - languageInfoView.setText(langText); - } - if (sourceTextView != null) { - String value = sourceText == null ? "" : sourceText; - sourceTextView.setText(TextUtils.isEmpty(value) ? "正在识别..." : value); - } - if (translatedTextView != null) { - String value = translatedText == null ? "" : translatedText; - translatedTextView.setText(TextUtils.isEmpty(value) ? "翻译中..." : value); - translatedTextView.setAlpha(isIntermediate ? 0.7f : 1.0f); - } - if (timestampView != null) { - timestampView.setText(timestamp > 0 ? formatTimestamp(timestamp) : ""); - } - - if (isMinimized) { - // keep minimized, content updated for next expand - } - } - - private void setPosition(Intent intent) { - if (layoutParams == null || windowManager == null) return; - int x = intent.getIntExtra(EXTRA_X, layoutParams.x); - int y = intent.getIntExtra(EXTRA_Y, layoutParams.y); - layoutParams.x = x; - layoutParams.y = y; - windowManager.updateViewLayout(floatingView, layoutParams); - } - - private void removeFloatingWindow() { - if (windowManager != null && floatingView != null) { - try { - windowManager.removeView(floatingView); - } catch (Exception ignored) { - } - floatingView = null; - } - notifyStateChanged("hidden"); - } - - private void notifyStateChanged(String state) { - MethodChannel channel = methodChannel; - if (channel == null) return; - Map args = new HashMap<>(); - args.put("state", state); - channel.invokeMethod("onFloatingWindowStateChanged", args); - } - - private void notifyClosed() { - MethodChannel channel = methodChannel; - if (channel == null) return; - channel.invokeMethod("onFloatingWindowClosed", null); - } - - private void notifyClicked() { - MethodChannel channel = methodChannel; - if (channel == null) return; - channel.invokeMethod("onFloatingWindowClicked", null); - } - - private String formatTimestamp(long ts) { - if (ts <= 0) return ""; - try { - SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - return sdf.format(new Date(ts)); - } catch (Exception ignored) { - return ""; - } - } - - private int dp(int value) { - return (int) (value * getResources().getDisplayMetrics().density); - } -} diff --git a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java deleted file mode 100644 index 2d0cb11c9..000000000 --- a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java +++ /dev/null @@ -1,314 +0,0 @@ -package com.example.native_plugin; - -import androidx.annotation.NonNull; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; - -import java.util.Map; - -import io.flutter.embedding.engine.plugins.FlutterPlugin; -import io.flutter.plugin.common.EventChannel; -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; - -/** NativePlugin */ -public class NativePlugin implements FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private MethodChannel channel; - private MethodChannel floatingWindowChannel; - private MethodChannel overlayPermissionChannel; - private EventChannel recordingFloatingActionsChannel; - private Context applicationContext; - - private static final Handler mainHandler = new Handler(Looper.getMainLooper()); - private static volatile EventChannel.EventSink recordingFloatingActionSink; - - @Override - public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { - applicationContext = flutterPluginBinding.getApplicationContext(); - channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin"); - channel.setMethodCallHandler(this); - - recordingFloatingActionsChannel = - new EventChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin/recording_floating_actions"); - recordingFloatingActionsChannel.setStreamHandler(this); - - floatingWindowChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "floating_window"); - floatingWindowChannel.setMethodCallHandler((call, result) -> handleFloatingWindowCall(call, result)); - FloatingWindowService.methodChannel = floatingWindowChannel; - - overlayPermissionChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "overlay_permission"); - overlayPermissionChannel.setMethodCallHandler((call, result) -> handleOverlayPermissionCall(call, result)); - } - - @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { - if (call.method.equals("getPlatformVersion")) { - result.success("Android " + android.os.Build.VERSION.RELEASE); - } else if (call.method.equals("showRecordingFloatingBar")) { - String duration = call.argument("duration"); - String recordingType = call.argument("recordingType"); - Boolean isPaused = call.argument("isPaused"); - startRecordingFloatingService( - RecordingFloatingService.ACTION_SHOW, - duration, - recordingType, - isPaused != null && isPaused - ); - result.success(true); - } else if (call.method.equals("updateRecordingFloatingBar")) { - String duration = call.argument("duration"); - String recordingType = call.argument("recordingType"); - Boolean isPaused = call.argument("isPaused"); - startRecordingFloatingService( - RecordingFloatingService.ACTION_UPDATE, - duration, - recordingType, - isPaused != null && isPaused - ); - result.success(true); - } else if (call.method.equals("hideRecordingFloatingBar")) { - startRecordingFloatingService(RecordingFloatingService.ACTION_HIDE, null, null, false); - result.success(true); - } else { - result.notImplemented(); - } - } - - private void startRecordingFloatingService( - String action, - String duration, - String recordingType, - boolean isPaused - ) { - if (applicationContext == null) return; - Intent intent = new Intent(applicationContext, RecordingFloatingService.class); - intent.setAction(action); - if (duration != null) { - intent.putExtra(RecordingFloatingService.EXTRA_DURATION, duration); - } - if (recordingType != null) { - intent.putExtra(RecordingFloatingService.EXTRA_RECORDING_TYPE, recordingType); - } - intent.putExtra(RecordingFloatingService.EXTRA_IS_PAUSED, isPaused); - applicationContext.startService(intent); - } - - private void handleFloatingWindowCall(@NonNull MethodCall call, @NonNull Result result) { - switch (call.method) { - case "enableFloatingWindow": - result.success(startFloatingWindowService(null, null)); - break; - case "disableFloatingWindow": - result.success(stopFloatingWindowService()); - break; - case "showFloatingWindow": - result.success(true); - break; - case "hideFloatingWindow": - try { - FloatingWindowService instance = FloatingWindowService.serviceInstance; - if (instance != null) { - instance.stopSelf(); - } else { - stopFloatingWindowService(); - } - result.success(true); - } catch (Exception e) { - result.error("HIDE_ERROR", "Failed to hide floating window: " + e.getMessage(), null); - } - break; - case "updateContent": - try { - @SuppressWarnings("unchecked") - Map args = (Map) call.arguments; - result.success(startFloatingWindowService(FloatingWindowService.ACTION_UPDATE_CONTENT, args)); - } catch (Exception e) { - result.error("UPDATE_ERROR", "Failed to update content: " + e.getMessage(), null); - } - break; - case "setPosition": - try { - @SuppressWarnings("unchecked") - Map args = (Map) call.arguments; - result.success(startFloatingWindowService(FloatingWindowService.ACTION_SET_POSITION, args)); - } catch (Exception e) { - result.error("POSITION_ERROR", "Failed to set position: " + e.getMessage(), null); - } - break; - default: - result.notImplemented(); - break; - } - } - - private boolean startFloatingWindowService(String action, Map args) { - if (applicationContext == null) return false; - try { - Intent intent = new Intent(applicationContext, FloatingWindowService.class); - if (action != null) { - intent.setAction(action); - } - if (args != null) { - if (FloatingWindowService.ACTION_UPDATE_CONTENT.equals(action)) { - Object sourceText = args.get("sourceText"); - Object translatedText = args.get("translatedText"); - Object sourceLanguage = args.get("sourceLanguage"); - Object targetLanguage = args.get("targetLanguage"); - Object timestamp = args.get("timestamp"); - Object isIntermediate = args.get("isIntermediate"); - - if (sourceText instanceof String) { - intent.putExtra(FloatingWindowService.EXTRA_SOURCE_TEXT, (String) sourceText); - } - if (translatedText instanceof String) { - intent.putExtra(FloatingWindowService.EXTRA_TRANSLATED_TEXT, (String) translatedText); - } - if (sourceLanguage instanceof String) { - intent.putExtra(FloatingWindowService.EXTRA_SOURCE_LANGUAGE, (String) sourceLanguage); - } - if (targetLanguage instanceof String) { - intent.putExtra(FloatingWindowService.EXTRA_TARGET_LANGUAGE, (String) targetLanguage); - } - if (timestamp instanceof Number) { - intent.putExtra(FloatingWindowService.EXTRA_TIMESTAMP, ((Number) timestamp).longValue()); - } - if (isIntermediate instanceof Boolean) { - intent.putExtra(FloatingWindowService.EXTRA_IS_INTERMEDIATE, (Boolean) isIntermediate); - } - } else if (FloatingWindowService.ACTION_SET_POSITION.equals(action)) { - Object x = args.get("x"); - Object y = args.get("y"); - if (x instanceof Number) { - intent.putExtra(FloatingWindowService.EXTRA_X, ((Number) x).intValue()); - } - if (y instanceof Number) { - intent.putExtra(FloatingWindowService.EXTRA_Y, ((Number) y).intValue()); - } - } - } - applicationContext.startService(intent); - return true; - } catch (Exception e) { - return false; - } - } - - private boolean stopFloatingWindowService() { - if (applicationContext == null) return false; - try { - Intent intent = new Intent(applicationContext, FloatingWindowService.class); - return applicationContext.stopService(intent); - } catch (Exception e) { - return false; - } - } - - private void handleOverlayPermissionCall(@NonNull MethodCall call, @NonNull Result result) { - switch (call.method) { - case "hasOverlayPermission": - result.success(hasOverlayPermission()); - break; - case "requestOverlayPermission": - result.success(requestOverlayPermission()); - break; - default: - result.notImplemented(); - break; - } - } - - private boolean hasOverlayPermission() { - if (applicationContext == null) return false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return Settings.canDrawOverlays(applicationContext); - } - return true; - } - - private boolean requestOverlayPermission() { - if (applicationContext == null) return false; - try { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return true; - } - if (hasOverlayPermission()) { - return true; - } - - boolean openedBySystem = startActivitySafe(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) - .setData(Uri.parse("package:" + applicationContext.getPackageName()))); - if (openedBySystem) return true; - - return startActivitySafe(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.parse("package:" + applicationContext.getPackageName()))); - } catch (Exception e) { - return false; - } - } - - private boolean startActivitySafe(@NonNull Intent intent) { - if (applicationContext == null) return false; - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PackageManager pm = applicationContext.getPackageManager(); - if (intent.resolveActivity(pm) == null) return false; - try { - applicationContext.startActivity(intent); - return true; - } catch (Exception e) { - return false; - } - } - - static void emitRecordingFloatingAction(String action) { - final EventChannel.EventSink sink = recordingFloatingActionSink; - if (sink == null) return; - mainHandler.post(() -> { - try { - sink.success(action); - } catch (Exception ignored) { - } - }); - } - - @Override - public void onListen(Object arguments, EventChannel.EventSink events) { - recordingFloatingActionSink = events; - } - - @Override - public void onCancel(Object arguments) { - recordingFloatingActionSink = null; - } - - @Override - public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - channel.setMethodCallHandler(null); - if (floatingWindowChannel != null) { - floatingWindowChannel.setMethodCallHandler(null); - } - if (overlayPermissionChannel != null) { - overlayPermissionChannel.setMethodCallHandler(null); - } - FloatingWindowService.methodChannel = null; - if (recordingFloatingActionsChannel != null) { - recordingFloatingActionsChannel.setStreamHandler(null); - } - recordingFloatingActionsChannel = null; - floatingWindowChannel = null; - overlayPermissionChannel = null; - applicationContext = null; - } -} diff --git a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java deleted file mode 100644 index 435786212..000000000 --- a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java +++ /dev/null @@ -1,292 +0,0 @@ -package com.example.native_plugin; - -import android.app.Service; -import android.content.Intent; -import android.graphics.Color; -import android.graphics.PixelFormat; -import android.graphics.drawable.GradientDrawable; -import android.os.Build; -import android.os.IBinder; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -public class RecordingFloatingService extends Service { - static final String ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"; - static final String ACTION_UPDATE = "com.example.native_plugin.action.UPDATE_RECORDING_FLOATING_BAR"; - static final String ACTION_HIDE = "com.example.native_plugin.action.HIDE_RECORDING_FLOATING_BAR"; - - static final String EXTRA_DURATION = "duration"; - static final String EXTRA_RECORDING_TYPE = "recordingType"; - static final String EXTRA_IS_PAUSED = "isPaused"; - - private WindowManager windowManager; - private View floatingView; - private WindowManager.LayoutParams layoutParams; - private TextView durationTextView; - private TextView typeTextView; - private TextView playPauseTextView; - private boolean isPaused = false; - - @Override - public void onCreate() { - super.onCreate(); - windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent == null) { - return START_NOT_STICKY; - } - - String action = intent.getAction(); - if (ACTION_HIDE.equals(action)) { - removeFloatingView(); - stopSelf(); - return START_NOT_STICKY; - } - - if (ACTION_SHOW.equals(action) || ACTION_UPDATE.equals(action)) { - String duration = intent.getStringExtra(EXTRA_DURATION); - String recordingType = intent.getStringExtra(EXTRA_RECORDING_TYPE); - boolean paused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false); - showOrUpdate(duration, recordingType, paused); - } - - return START_STICKY; - } - - private void showOrUpdate(String duration, String recordingType, boolean paused) { - if (floatingView == null) { - createFloatingView(); - addFloatingView(); - } - updateContent(duration, recordingType, paused); - } - - private void updateContent(String duration, String recordingType, boolean paused) { - isPaused = paused; - updatePlayPauseIcon(); - if (durationTextView != null) { - durationTextView.setText(duration == null ? "" : duration); - } - if (typeTextView != null) { - String type = recordingType == null ? "" : recordingType.trim(); - if (type.isEmpty()) { - typeTextView.setVisibility(View.GONE); - } else { - typeTextView.setVisibility(View.VISIBLE); - typeTextView.setText(type); - } - } - } - - private void updatePlayPauseIcon() { - if (playPauseTextView == null) return; - playPauseTextView.setText(isPaused ? "▶" : "⏸"); - } - - private void createFloatingView() { - float density = getResources().getDisplayMetrics().density; - int touchSlop = ViewConfiguration.get(this).getScaledTouchSlop(); - - LinearLayout container = new LinearLayout(this); - container.setOrientation(LinearLayout.HORIZONTAL); - container.setGravity(Gravity.CENTER_VERTICAL); - int paddingH = (int) (10 * density); - int paddingV = (int) (6 * density); - container.setPadding(paddingH, paddingV, paddingH, paddingV); - - GradientDrawable bg = new GradientDrawable(); - bg.setColor(Color.parseColor("#1A1A1A")); - bg.setCornerRadius(999 * density); - container.setBackground(bg); - - playPauseTextView = new TextView(this); - int playSize = (int) (26 * density); - LinearLayout.LayoutParams playLp = new LinearLayout.LayoutParams(playSize, playSize); - playLp.rightMargin = (int) (10 * density); - playPauseTextView.setLayoutParams(playLp); - playPauseTextView.setTextColor(Color.WHITE); - playPauseTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); - playPauseTextView.setGravity(Gravity.CENTER); - GradientDrawable playBg = new GradientDrawable(); - playBg.setColor(Color.parseColor("#FF3B30")); - playBg.setCornerRadius(999 * density); - playPauseTextView.setBackground(playBg); - playPauseTextView.setOnClickListener(v -> { - isPaused = !isPaused; - updatePlayPauseIcon(); - NativePlugin.emitRecordingFloatingAction("togglePause"); - }); - updatePlayPauseIcon(); - container.addView(playPauseTextView); - - LinearLayout middleArea = new LinearLayout(this); - middleArea.setOrientation(LinearLayout.HORIZONTAL); - middleArea.setGravity(Gravity.CENTER_VERTICAL); - LinearLayout.LayoutParams middleLp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - middleLp.rightMargin = (int) (10 * density); - middleArea.setLayoutParams(middleLp); - middleArea.setClickable(true); - middleArea.setFocusable(true); - - durationTextView = new TextView(this); - durationTextView.setTextColor(Color.WHITE); - durationTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - LinearLayout.LayoutParams durationLp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - durationLp.rightMargin = (int) (8 * density); - durationTextView.setLayoutParams(durationLp); - durationTextView.setText("00:00"); - middleArea.addView(durationTextView); - - typeTextView = new TextView(this); - typeTextView.setTextColor(Color.parseColor("#E6E6E6")); - typeTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); - LinearLayout.LayoutParams typeLp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - typeTextView.setLayoutParams(typeLp); - typeTextView.setVisibility(View.GONE); - middleArea.addView(typeTextView); - - middleArea.setOnClickListener(v -> { - bringAppToFront(); - NativePlugin.emitRecordingFloatingAction("openMeetingRecord"); - }); - - middleArea.setOnTouchListener(new View.OnTouchListener() { - private int initialY; - private float initialTouchY; - private boolean dragging; - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (layoutParams == null || windowManager == null) return false; - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - initialY = layoutParams.y; - initialTouchY = event.getRawY(); - dragging = false; - return true; - case MotionEvent.ACTION_MOVE: - int dy = (int) (event.getRawY() - initialTouchY); - if (!dragging && Math.abs(dy) > touchSlop) { - dragging = true; - } - if (!dragging) return true; - - int newY = initialY + dy; - int screenHeight = getResources().getDisplayMetrics().heightPixels; - int viewHeight = floatingView == null ? 0 : floatingView.getHeight(); - int maxY = Math.max(0, screenHeight - viewHeight - (int) (24 * density)); - if (newY < 0) newY = 0; - if (newY > maxY) newY = maxY; - layoutParams.y = newY; - windowManager.updateViewLayout(floatingView, layoutParams); - return true; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - if (!dragging) { - v.performClick(); - } - return true; - default: - return false; - } - } - }); - - container.addView(middleArea); - - TextView close = new TextView(this); - int closeSize = (int) (22 * density); - LinearLayout.LayoutParams closeLp = new LinearLayout.LayoutParams(closeSize, closeSize); - close.setLayoutParams(closeLp); - close.setText("×"); - close.setTextColor(Color.WHITE); - close.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); - close.setGravity(Gravity.CENTER); - close.setOnClickListener(v -> { - removeFloatingView(); - stopSelf(); - NativePlugin.emitRecordingFloatingAction("closeFloatingBar"); - }); - container.addView(close); - - floatingView = container; - } - - private void addFloatingView() { - if (windowManager == null || floatingView == null) return; - layoutParams = new WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - : WindowManager.LayoutParams.TYPE_PHONE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN - | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, - PixelFormat.TRANSLUCENT - ); - - layoutParams.gravity = Gravity.TOP | Gravity.END; - float density = getResources().getDisplayMetrics().density; - layoutParams.x = (int) (6 * density); - layoutParams.y = (int) (48 * density); - - windowManager.addView(floatingView, layoutParams); - } - - private void removeFloatingView() { - if (windowManager != null && floatingView != null) { - try { - windowManager.removeView(floatingView); - } catch (Exception ignored) { - } - floatingView = null; - layoutParams = null; - durationTextView = null; - typeTextView = null; - playPauseTextView = null; - } - } - - private void bringAppToFront() { - try { - Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); - if (launchIntent == null) return; - launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(launchIntent); - } catch (Exception ignored) { - } - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public void onDestroy() { - removeFloatingView(); - super.onDestroy(); - } -} diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt new file mode 100644 index 000000000..7e8a4b0da --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt @@ -0,0 +1,403 @@ +package com.example.native_plugin + +import android.app.Service +import android.content.Intent +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.IBinder +import android.text.TextUtils +import android.util.TypedValue +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import io.flutter.plugin.common.MethodChannel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class FloatingWindowService : Service() { + companion object { + 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" + + @Volatile + var methodChannel: MethodChannel? = null + + @Volatile + var serviceInstance: FloatingWindowService? = null + } + + private var windowManager: WindowManager? = null + private var floatingView: View? = null + private var layoutParams: WindowManager.LayoutParams? = null + private var isMinimized: Boolean = true + + private var sourceTextView: TextView? = null + private var translatedTextView: TextView? = null + private var languageInfoView: TextView? = null + private var timestampView: TextView? = null + private var minimizedView: View? = null + private var expandedView: View? = null + private var closeButton: TextView? = null + + override fun onCreate() { + super.onCreate() + serviceInstance = this + createFloatingWindow() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_UPDATE_CONTENT -> updateContent(intent) + ACTION_SET_POSITION -> setPosition(intent) + } + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + removeFloatingWindow() + serviceInstance = null + super.onDestroy() + } + + private fun createFloatingWindow() { + windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager ?: return + + floatingView = createFloatingLayout() + initViews() + setupClickListeners() + setupDragListener() + + layoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.START + x = dp(100) + y = dp(100) + } + + windowManager?.addView(floatingView, layoutParams) + updateViewState(true) + } + + private fun createFloatingLayout(): View { + val root = FrameLayout(this).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + minimizedView = createMinimizedView() + expandedView = createExpandedView() + + root.addView(minimizedView) + root.addView(expandedView) + return root + } + + private fun createMinimizedView(): View { + val container = FrameLayout(this) + val lp = FrameLayout.LayoutParams(dp(60), dp(60)) + val margin = dp(8) + lp.setMargins(margin, margin, margin, margin) + container.layoutParams = lp + + val bg = GradientDrawable().apply { + setColor(0xFFE3F2FD.toInt()) + cornerRadius = dp(30).toFloat() + } + container.background = bg + container.elevation = dp(8).toFloat() + + val icon = TextView(this).apply { + text = "译" + setTextColor(0xFF1976D2.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + } + container.addView( + icon, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + return container + } + + private fun createExpandedView(): View { + val container = LinearLayout(this) + val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT) + val margin = dp(8) + lp.setMargins(margin, margin, margin, margin) + container.layoutParams = lp + container.orientation = LinearLayout.VERTICAL + container.setPadding(dp(16), dp(16), dp(16), dp(16)) + container.visibility = View.GONE + container.elevation = dp(12).toFloat() + + val bg = GradientDrawable().apply { + setColor(Color.WHITE) + cornerRadius = dp(16).toFloat() + } + container.background = bg + + val titleRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + val title = TextView(this).apply { + text = "实时翻译" + setTextColor(0xFF333333.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + typeface = Typeface.DEFAULT_BOLD + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + } + + closeButton = TextView(this).apply { + text = "✕" + setTextColor(0xFF666666.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + gravity = Gravity.CENTER + setPadding(dp(8), dp(8), dp(8), dp(8)) + } + + titleRow.addView(title) + titleRow.addView(closeButton) + + languageInfoView = TextView(this).apply { + text = "中文 → 英文" + setTextColor(0xFF666666.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setPadding(0, dp(8), 0, dp(8)) + } + + sourceTextView = TextView(this).apply { + text = "正在识别..." + setTextColor(0xFF333333.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, dp(8), 0, dp(4)) + maxLines = 3 + ellipsize = TextUtils.TruncateAt.END + } + + translatedTextView = TextView(this).apply { + text = "翻译中..." + setTextColor(0xFF1976D2.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, dp(4), 0, dp(8)) + maxLines = 3 + ellipsize = TextUtils.TruncateAt.END + } + + timestampView = TextView(this).apply { + text = "" + setTextColor(0xFF999999.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) + gravity = Gravity.END + } + + container.addView(titleRow) + container.addView(languageInfoView) + container.addView(sourceTextView) + container.addView(translatedTextView) + container.addView(timestampView) + return container + } + + private fun initViews() { + } + + private fun setupClickListeners() { + floatingView?.setOnLongClickListener { + stopSelf() + notifyClosed() + true + } + + closeButton?.setOnClickListener { + stopSelf() + notifyClosed() + } + } + + private fun setupDragListener() { + val root = floatingView ?: return + val threshold = dp(4) + + root.setOnTouchListener(object : View.OnTouchListener { + private var initialX: Int = 0 + private var initialY: Int = 0 + private var initialTouchX: Float = 0f + private var initialTouchY: Float = 0f + private var dragging: Boolean = false + + override fun onTouch(v: View, event: MotionEvent): Boolean { + val lp = layoutParams ?: return false + val wm = windowManager ?: return false + when (event.action) { + MotionEvent.ACTION_DOWN -> { + initialX = lp.x + initialY = lp.y + initialTouchX = event.rawX + initialTouchY = event.rawY + dragging = false + return true + } + + MotionEvent.ACTION_MOVE -> { + val dx = (event.rawX - initialTouchX).toInt() + val dy = (event.rawY - initialTouchY).toInt() + if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { + dragging = true + } + val maxX = + resources.displayMetrics.widthPixels - (floatingView?.width ?: 0) + val maxY = + resources.displayMetrics.heightPixels - (floatingView?.height ?: 0) + var nextX = initialX + dx + var nextY = initialY + dy + if (maxX > 0) nextX = nextX.coerceIn(0, maxX) + if (maxY > 0) nextY = nextY.coerceIn(0, maxY) + lp.x = nextX + lp.y = nextY + wm.updateViewLayout(floatingView, lp) + return true + } + + MotionEvent.ACTION_UP -> { + if (!dragging) updateViewState(!isMinimized) + dragging = false + return true + } + + else -> return false + } + } + }) + } + + private fun updateViewState(minimized: Boolean) { + isMinimized = minimized + minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE + expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE + notifyStateChanged(if (minimized) "minimized" else "expanded") + } + + private fun updateContent(intent: Intent) { + val sourceText = intent.getStringExtra(EXTRA_SOURCE_TEXT) + val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) + val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) + val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) + val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L) + val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) + + languageInfoView?.let { view -> + val langText = (sourceLang ?: "") + " → " + (targetLang ?: "") + view.text = langText + } + + sourceTextView?.let { view -> + val value = sourceText ?: "" + view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value + } + + translatedTextView?.let { view -> + val value = translatedText ?: "" + view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value + view.alpha = if (isIntermediate) 0.7f else 1.0f + } + + timestampView?.text = if (timestamp > 0) formatTimestamp(timestamp) else "" + } + + private fun setPosition(intent: Intent) { + val lp = layoutParams ?: return + val wm = windowManager ?: return + lp.x = intent.getIntExtra(EXTRA_X, lp.x) + lp.y = intent.getIntExtra(EXTRA_Y, lp.y) + wm.updateViewLayout(floatingView, lp) + } + + private fun removeFloatingWindow() { + val wm = windowManager + val view = floatingView + if (wm != null && view != null) { + try { + wm.removeView(view) + } catch (_: Exception) { + } + floatingView = null + } + notifyStateChanged("hidden") + } + + private fun notifyStateChanged(state: String) { + val channel = methodChannel ?: return + channel.invokeMethod("onFloatingWindowStateChanged", mapOf("state" to state)) + } + + private fun notifyClosed() { + val channel = methodChannel ?: return + channel.invokeMethod("onFloatingWindowClosed", null) + } + + private fun notifyClicked() { + val channel = methodChannel ?: return + channel.invokeMethod("onFloatingWindowClicked", null) + } + + private fun formatTimestamp(ts: Long): String { + if (ts <= 0) return "" + return try { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + sdf.format(Date(ts)) + } 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/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt new file mode 100644 index 000000000..f9f9ba0a0 --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt @@ -0,0 +1,254 @@ +package com.example.native_plugin + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +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 NativePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { + private lateinit var channel: MethodChannel + private lateinit var floatingWindowChannel: MethodChannel + private lateinit var overlayPermissionChannel: MethodChannel + private lateinit var recordingFloatingActionsChannel: EventChannel + private var applicationContext: Context? = null + + companion object { + private var recordingFloatingActionSink: EventChannel.EventSink? = null + private val mainHandler = Handler(Looper.getMainLooper()) + @JvmStatic + fun emitRecordingFloatingAction(action: String) { + val sink = recordingFloatingActionSink ?: return + mainHandler.post { + try { + sink.success(action) + } catch (_: Exception) { + } + } + } + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "native_plugin") + channel.setMethodCallHandler(this) + + recordingFloatingActionsChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "native_plugin/recording_floating_actions") + recordingFloatingActionsChannel.setStreamHandler(this) + + floatingWindowChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "floating_window") + floatingWindowChannel.setMethodCallHandler { call, result -> handleFloatingWindowCall(call, result) } + FloatingWindowService.methodChannel = floatingWindowChannel + + overlayPermissionChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "overlay_permission") + overlayPermissionChannel.setMethodCallHandler { call, result -> handleOverlayPermissionCall(call, result) } + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android " + Build.VERSION.RELEASE) + } + "showRecordingFloatingBar" -> { + val duration = call.argument("duration") + val recordingType = call.argument("recordingType") + val isPaused = call.argument("isPaused") ?: false + startRecordingFloatingService( + RecordingFloatingService.ACTION_SHOW, + duration, + recordingType, + isPaused + ) + result.success(true) + } + "updateRecordingFloatingBar" -> { + val duration = call.argument("duration") + val recordingType = call.argument("recordingType") + val isPaused = call.argument("isPaused") ?: false + startRecordingFloatingService( + RecordingFloatingService.ACTION_UPDATE, + duration, + recordingType, + isPaused + ) + result.success(true) + } + "hideRecordingFloatingBar" -> { + startRecordingFloatingService(RecordingFloatingService.ACTION_HIDE, null, null, false) + result.success(true) + } + else -> result.notImplemented() + } + } + + private fun startRecordingFloatingService( + action: String, + duration: String?, + recordingType: String?, + isPaused: Boolean + ) { + val ctx = applicationContext ?: return + val intent = Intent(ctx, RecordingFloatingService::class.java) + intent.action = action + if (duration != null) intent.putExtra(RecordingFloatingService.EXTRA_DURATION, duration) + if (recordingType != null) intent.putExtra(RecordingFloatingService.EXTRA_RECORDING_TYPE, recordingType) + intent.putExtra(RecordingFloatingService.EXTRA_IS_PAUSED, isPaused) + ctx.startService(intent) + } + + private fun handleFloatingWindowCall(call: MethodCall, result: Result) { + when (call.method) { + "enableFloatingWindow" -> { + result.success(startFloatingWindowService(null, null)) + } + "disableFloatingWindow" -> { + result.success(stopFloatingWindowService()) + } + "showFloatingWindow" -> { + result.success(startFloatingWindowService(null, null)) + } + "hideFloatingWindow" -> { + try { + val instance = FloatingWindowService.serviceInstance + if (instance != null) { + instance.stopSelf() + } else { + stopFloatingWindowService() + } + result.success(true) + } catch (e: Exception) { + result.error("HIDE_ERROR", "Failed to hide floating window: ${e.message}", null) + } + } + "updateContent" -> { + try { + val args = call.arguments as? Map + result.success(startFloatingWindowService(FloatingWindowService.ACTION_UPDATE_CONTENT, args)) + } catch (e: Exception) { + result.error("UPDATE_ERROR", "Failed to update content: ${e.message}", null) + } + } + "setPosition" -> { + try { + val args = call.arguments as? Map + result.success(startFloatingWindowService(FloatingWindowService.ACTION_SET_POSITION, args)) + } catch (e: Exception) { + result.error("POSITION_ERROR", "Failed to set position: ${e.message}", null) + } + } + else -> result.notImplemented() + } + } + + private fun startFloatingWindowService(action: String?, args: Map?): Boolean { + val ctx = applicationContext ?: return false + return try { + val intent = Intent(ctx, FloatingWindowService::class.java) + if (action != null) intent.action = action + if (args != null) { + if (FloatingWindowService.ACTION_UPDATE_CONTENT == action) { + (args["sourceText"] as? String)?.let { intent.putExtra(FloatingWindowService.EXTRA_SOURCE_TEXT, it) } + (args["translatedText"] as? String)?.let { intent.putExtra(FloatingWindowService.EXTRA_TRANSLATED_TEXT, it) } + (args["sourceLanguage"] as? String)?.let { intent.putExtra(FloatingWindowService.EXTRA_SOURCE_LANGUAGE, it) } + (args["targetLanguage"] as? String)?.let { intent.putExtra(FloatingWindowService.EXTRA_TARGET_LANGUAGE, it) } + (args["timestamp"] as? Number)?.let { intent.putExtra(FloatingWindowService.EXTRA_TIMESTAMP, it.toLong()) } + (args["isIntermediate"] as? Boolean)?.let { intent.putExtra(FloatingWindowService.EXTRA_IS_INTERMEDIATE, it) } + } else if (FloatingWindowService.ACTION_SET_POSITION == action) { + (args["x"] as? Number)?.let { intent.putExtra(FloatingWindowService.EXTRA_X, it.toInt()) } + (args["y"] as? Number)?.let { intent.putExtra(FloatingWindowService.EXTRA_Y, it.toInt()) } + } + } + ctx.startService(intent) + true + } catch (_: Exception) { + false + } + } + + private fun stopFloatingWindowService(): Boolean { + val ctx = applicationContext ?: return false + return try { + val intent = Intent(ctx, FloatingWindowService::class.java) + ctx.stopService(intent) + true + } catch (_: Exception) { + false + } + } + + private fun handleOverlayPermissionCall(call: MethodCall, result: Result) { + when (call.method) { + "hasOverlayPermission" -> result.success(hasOverlayPermission()) + "requestOverlayPermission" -> result.success(requestOverlayPermission()) + else -> result.notImplemented() + } + } + + private fun hasOverlayPermission(): Boolean { + val ctx = applicationContext ?: return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(ctx) + } else { + true + } + } + + private fun requestOverlayPermission(): Boolean { + val ctx = applicationContext ?: return false + return try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + if (hasOverlayPermission()) return true + val openedBySystem = startActivitySafe(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { + data = Uri.parse("package:${ctx.packageName}") + }) + if (openedBySystem) return true + startActivitySafe(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${ctx.packageName}") + }) + } catch (_: Exception) { + false + } + } + + private fun startActivitySafe(intent: Intent): Boolean { + val ctx = applicationContext ?: return false + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pm: PackageManager = ctx.packageManager + if (intent.resolveActivity(pm) == null) return false + return try { + ctx.startActivity(intent) + true + } catch (_: Exception) { + false + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + recordingFloatingActionSink = events + } + + override fun onCancel(arguments: Any?) { + recordingFloatingActionSink = null + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + floatingWindowChannel.setMethodCallHandler(null) + overlayPermissionChannel.setMethodCallHandler(null) + FloatingWindowService.methodChannel = null + recordingFloatingActionsChannel.setStreamHandler(null) + recordingFloatingActionSink = null + applicationContext = null + } +} diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt new file mode 100644 index 000000000..269dcea3a --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt @@ -0,0 +1,297 @@ +package com.example.native_plugin + +import android.app.Service +import android.content.Intent +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.IBinder +import android.util.TypedValue +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.TextView + +class RecordingFloatingService : Service() { + companion object { + const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR" + const val ACTION_UPDATE = "com.example.native_plugin.action.UPDATE_RECORDING_FLOATING_BAR" + const val ACTION_HIDE = "com.example.native_plugin.action.HIDE_RECORDING_FLOATING_BAR" + + const val EXTRA_DURATION = "duration" + const val EXTRA_RECORDING_TYPE = "recordingType" + const val EXTRA_IS_PAUSED = "isPaused" + } + + private var windowManager: WindowManager? = null + private var floatingView: View? = null + private var layoutParams: WindowManager.LayoutParams? = null + private var durationTextView: TextView? = null + private var typeTextView: TextView? = null + private var playPauseTextView: TextView? = null + private var isPaused: Boolean = false + + override fun onCreate() { + super.onCreate() + windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return START_NOT_STICKY + + when (intent.action) { + ACTION_HIDE -> { + removeFloatingView() + stopSelf() + return START_NOT_STICKY + } + + ACTION_SHOW, ACTION_UPDATE -> { + val duration = intent.getStringExtra(EXTRA_DURATION) + val recordingType = intent.getStringExtra(EXTRA_RECORDING_TYPE) + val paused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false) + showOrUpdate(duration, recordingType, paused) + } + } + + return START_STICKY + } + + private fun showOrUpdate(duration: String?, recordingType: String?, paused: Boolean) { + if (floatingView == null) { + createFloatingView() + addFloatingView() + } + updateContent(duration, recordingType, paused) + } + + private fun updateContent(duration: String?, recordingType: String?, paused: Boolean) { + isPaused = paused + updatePlayPauseIcon() + durationTextView?.text = duration ?: "" + + typeTextView?.let { view -> + val type = (recordingType ?: "").trim() + if (type.isEmpty()) { + view.visibility = View.GONE + } else { + view.visibility = View.VISIBLE + view.text = type + } + } + } + + private fun updatePlayPauseIcon() { + playPauseTextView?.text = if (isPaused) "▶" else "⏸" + } + + private fun createFloatingView() { + val density = resources.displayMetrics.density + val touchSlop = ViewConfiguration.get(this).scaledTouchSlop + + val container = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val paddingH = (10 * density).toInt() + val paddingV = (6 * density).toInt() + setPadding(paddingH, paddingV, paddingH, paddingV) + + val bg = GradientDrawable().apply { + setColor(Color.parseColor("#1A1A1A")) + cornerRadius = 999 * density + } + background = bg + } + + playPauseTextView = TextView(this).apply { + val playSize = (26 * density).toInt() + layoutParams = LinearLayout.LayoutParams(playSize, playSize).apply { + rightMargin = (10 * density).toInt() + } + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + gravity = Gravity.CENTER + background = GradientDrawable().apply { + setColor(Color.parseColor("#FF3B30")) + cornerRadius = 999 * density + } + setOnClickListener { + isPaused = !isPaused + updatePlayPauseIcon() + NativePlugin.emitRecordingFloatingAction("togglePause") + } + } + updatePlayPauseIcon() + container.addView(playPauseTextView) + + val middleArea = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + rightMargin = (10 * density).toInt() + } + isClickable = true + isFocusable = true + } + + durationTextView = TextView(this).apply { + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + rightMargin = (8 * density).toInt() + } + text = "00:00" + } + middleArea.addView(durationTextView) + + typeTextView = TextView(this).apply { + setTextColor(Color.parseColor("#E6E6E6")) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + visibility = View.GONE + } + middleArea.addView(typeTextView) + + middleArea.setOnClickListener { + bringAppToFront() + NativePlugin.emitRecordingFloatingAction("openMeetingRecord") + } + + middleArea.setOnTouchListener(object : View.OnTouchListener { + private var initialY: Int = 0 + private var initialTouchY: Float = 0f + private var dragging: Boolean = false + + override fun onTouch(v: View, event: MotionEvent): Boolean { + val lp = layoutParams ?: return false + val wm = windowManager ?: return false + when (event.action) { + MotionEvent.ACTION_DOWN -> { + initialY = lp.y + initialTouchY = event.rawY + dragging = false + return true + } + + MotionEvent.ACTION_MOVE -> { + val dy = (event.rawY - initialTouchY).toInt() + if (!dragging && kotlin.math.abs(dy) > touchSlop) dragging = true + if (!dragging) return true + + var newY = initialY + dy + val screenHeight = resources.displayMetrics.heightPixels + val viewHeight = floatingView?.height ?: 0 + val maxY = kotlin.math.max(0, screenHeight - viewHeight - (24 * density).toInt()) + newY = newY.coerceIn(0, maxY) + lp.y = newY + wm.updateViewLayout(floatingView, lp) + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (!dragging) v.performClick() + return true + } + + else -> return false + } + } + }) + + container.addView(middleArea) + + val close = TextView(this).apply { + val closeSize = (22 * density).toInt() + layoutParams = LinearLayout.LayoutParams(closeSize, closeSize) + text = "×" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + gravity = Gravity.CENTER + setOnClickListener { + removeFloatingView() + stopSelf() + NativePlugin.emitRecordingFloatingAction("closeFloatingBar") + } + } + container.addView(close) + + floatingView = container + } + + private fun addFloatingView() { + val wm = windowManager ?: return + val view = floatingView ?: return + + layoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.END + val density = resources.displayMetrics.density + x = (6 * density).toInt() + y = (48 * density).toInt() + } + + wm.addView(view, layoutParams) + } + + private fun removeFloatingView() { + val wm = windowManager + val view = floatingView + if (wm != null && view != null) { + try { + wm.removeView(view) + } catch (_: Exception) { + } + floatingView = null + layoutParams = null + durationTextView = null + typeTextView = null + playPauseTextView = 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) { + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + removeFloatingView() + super.onDestroy() + } +} + diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.h b/local_plugins/native_plugin/ios/Classes/FLNativeView.h deleted file mode 100644 index 4c49e00f4..000000000 --- a/local_plugins/native_plugin/ios/Classes/FLNativeView.h +++ /dev/null @@ -1,15 +0,0 @@ -#import - -@interface FLNativeViewFactory : NSObject -- (instancetype)initWithMessenger:(NSObject *)messenger; -@end - -@interface FLNativeView : NSObject - -- (instancetype)initWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args - binaryMessenger:(NSObject *)messenger; - -- (UIView *)view; -@end diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.m b/local_plugins/native_plugin/ios/Classes/FLNativeView.m deleted file mode 100644 index 848785a6b..000000000 --- a/local_plugins/native_plugin/ios/Classes/FLNativeView.m +++ /dev/null @@ -1,83 +0,0 @@ -#import "FLNativeView.h" -#import "PlayerView.h" - -@implementation FLNativeViewFactory { - NSObject *_messenger; -} - -- (instancetype)initWithMessenger: - (NSObject *)messenger { - self = [super init]; - if (self) { - _messenger = messenger; - } - return self; -} - -- (NSObject *)createWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args { - return [[FLNativeView alloc] initWithFrame:frame - viewIdentifier:viewId - arguments:args - binaryMessenger:_messenger]; -} - -/// Implementing this method is only necessary when the `arguments` in -/// `createWithFrame` is not `nil`. -- (NSObject *)createArgsCodec { - return [FlutterStandardMessageCodec sharedInstance]; -} - -@end - -@implementation FLNativeView { - PlayerView *_view; - FlutterMethodChannel *_methodChannel; -} - -- (instancetype)initWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args - binaryMessenger:(NSObject *)messenger { - if (self = [super init]) { - _view = [[PlayerView alloc] init]; - _methodChannel = [FlutterMethodChannel - methodChannelWithName: - [NSString - stringWithFormat:@"native_plugin/native_view_%lld", viewId] - binaryMessenger:messenger]; - __weak typeof(self) weakSelf = self; - [_methodChannel setMethodCallHandler:^(FlutterMethodCall *_Nonnull call, - FlutterResult _Nonnull result) { - typeof(self) strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf onMethodCall:call result:result]; - } - }]; - } - return self; -} - -- (UIView *)view { - return _view; -} - -- (void)onMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { - if ([@"getInternalView" isEqualToString:call.method]) { - result(@((uint64_t)(_view))); - } else if ([@"updateContent" isEqualToString:call.method]) { - NSDictionary *data = call.arguments; - NSString *lang = data[@"lang"]; - NSString *cn = data[@"cn"]; - NSString *en = data[@"en"]; - dispatch_async(dispatch_get_main_queue(), ^{ - [_view updateWithLang:lang cn:cn en:en]; - }); - result(nil); - } else { - result(@(0)); - } -} - -@end diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.swift b/local_plugins/native_plugin/ios/Classes/FLNativeView.swift new file mode 100644 index 000000000..da658aba1 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/FLNativeView.swift @@ -0,0 +1,70 @@ +import Flutter +import UIKit + +@objc(FLNativeViewFactory) +public class FLNativeViewFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + public init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + public func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return FLNativeView(frame: frame, viewIdentifier: viewId, arguments: args, binaryMessenger: messenger) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} + +public class FLNativeView: NSObject, FlutterPlatformView { + private let viewInstance: PlayerView + private let methodChannel: FlutterMethodChannel + + public init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + binaryMessenger messenger: FlutterBinaryMessenger + ) { + viewInstance = PlayerView() + methodChannel = FlutterMethodChannel(name: "native_plugin/native_view_\(viewId)", binaryMessenger: messenger) + super.init() + methodChannel.setMethodCallHandler { [weak self] call, result in + self?.onMethodCall(call, result: result) + } + } + + public func view() -> UIView { + return viewInstance + } + + private func onMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getInternalView": + let ptr = Unmanaged.passUnretained(viewInstance).toOpaque() + let viewId = UInt64(UInt(bitPattern: ptr)) + result(NSNumber(value: viewId)) + + case "updateContent": + let data = call.arguments as? [String: Any] + let lang = data?["lang"] as? String + let cn = data?["cn"] as? String + let en = data?["en"] as? String + DispatchQueue.main.async { [weak self] in + self?.viewInstance.update(withLang: lang, cn: cn, en: en) + } + result(nil) + + default: + result(NSNumber(value: 0)) + } + } +} + diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.h b/local_plugins/native_plugin/ios/Classes/NativePlugin.h deleted file mode 100644 index 2d0b3293e..000000000 --- a/local_plugins/native_plugin/ios/Classes/NativePlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface NativePlugin : NSObject -@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.m b/local_plugins/native_plugin/ios/Classes/NativePlugin.m deleted file mode 100644 index bee63b337..000000000 --- a/local_plugins/native_plugin/ios/Classes/NativePlugin.m +++ /dev/null @@ -1,114 +0,0 @@ -#import "NativePlugin.h" -#import "FLNativeView.h" -#import "PlayerView.h" - -#import - -@interface NativePlugin () - -@property(nonatomic, strong) NSMutableArray *_Nonnull pipContentViewArray; - -@end - -@implementation NativePlugin - -+ (void)updateAudioSession { - AVAudioSession *audioSession = [AVAudioSession sharedInstance]; - NSError *categoryError = nil; - if (@available(iOS 14.5, *)) { - [audioSession - setCategory:AVAudioSessionCategoryPlayback - mode:AVAudioSessionModeMoviePlayback - options: - AVAudioSessionCategoryOptionOverrideMutedMicrophoneInterruption - error:&categoryError]; - } else { - // Fallback on earlier versions - } - if (categoryError) { - NSLog(@"Set audio session category error: %@", - categoryError.localizedDescription); - } - NSError *activeError = nil; - [audioSession setActive:YES error:&activeError]; - if (activeError) { - NSLog(@"Set audio session active error: %@", - activeError.localizedDescription); - } -} - -+ (void)registerWithRegistrar:(NSObject *)registrar { - // must call this, otherwise pip will not possible - // https://github.com/jazzychad/PiPBugDemo - [NativePlugin updateAudioSession]; - - FlutterMethodChannel *channel = - [FlutterMethodChannel methodChannelWithName:@"native_plugin" - binaryMessenger:[registrar messenger]]; - NativePlugin *instance = [[NativePlugin alloc] init]; - - FLNativeViewFactory *factory = - [[FLNativeViewFactory alloc] initWithMessenger:registrar.messenger]; - [registrar registerViewFactory:factory withId:@"native_view"]; - - [registrar addMethodCallDelegate:instance channel:channel]; -} - -- (instancetype)init { - self = [super init]; - if (self) { - _pipContentViewArray = [NSMutableArray array]; - } - return self; -} - -- (void)handleMethodCall:(FlutterMethodCall *)call - result:(FlutterResult)result { - if ([@"getPlatformVersion" isEqualToString:call.method]) { - result([@"iOS " - stringByAppendingString:[[UIDevice currentDevice] systemVersion]]); - } else if ([@"createPipContentView" isEqualToString:call.method]) { - PlayerView *playerView = [[PlayerView alloc] init]; - - // should control the play state by event of pip, optimize it later - [playerView play]; - - [self.pipContentViewArray addObject:playerView]; - - result(@((uint64_t)playerView)); - } else if ([@"disposePipContentView" isEqualToString:call.method]) { - uint64_t viewId = [call.arguments unsignedLongLongValue]; - for (PlayerView *playerView in self.pipContentViewArray) { - if ((uint64_t)playerView == viewId) { - [playerView pause]; - [self.pipContentViewArray removeObject:playerView]; - break; - } - } - } else if ([@"updatePipContentView" isEqualToString:call.method]) { - NSDictionary *data = call.arguments; - NSString *lang = data[@"lang"]; - NSString *cn = data[@"cn"]; - NSString *en = data[@"en"]; - NSNumber *viewIdNum = data[@"viewId"]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (viewIdNum != nil) { - uint64_t targetId = viewIdNum.unsignedLongLongValue; - for (PlayerView *playerView in self.pipContentViewArray) { - if ((uint64_t)playerView == targetId) { - [playerView updateWithLang:lang cn:cn en:en]; - break; - } - } - } else { - PlayerView *playerView = [self.pipContentViewArray lastObject]; - [playerView updateWithLang:lang cn:cn en:en]; - } - }); - result(nil); - } else { - result(FlutterMethodNotImplemented); - } -} - -@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.swift b/local_plugins/native_plugin/ios/Classes/NativePlugin.swift new file mode 100644 index 000000000..9dc4a1fad --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/NativePlugin.swift @@ -0,0 +1,95 @@ +import Flutter +import AVKit +import UIKit + +@objc(FloatingUiPlugin) +public class FloatingUiPlugin: NSObject, FlutterPlugin { + private var pipContentViewArray: [PlayerView] = [] + + private static func updateAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + do { + if #available(iOS 14.5, *) { + try audioSession.setCategory(.playback, mode: .moviePlayback, options: [.overrideMutedMicrophoneInterruption]) + } + try audioSession.setActive(true) + } catch { + NSLog("Set audio session error: \(error.localizedDescription)") + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + // 保证 PiP 能正常工作 + Self.updateAudioSession() + + let channel = FlutterMethodChannel(name: "native_plugin", binaryMessenger: registrar.messenger()) + let instance = FloatingUiPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + let factory = FLNativeViewFactory(messenger: registrar.messenger()) + registrar.register(factory, withId: "native_view") + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + + case "createPipContentView": + let playerView = PlayerView() + // 这里未来可以根据 PiP 的事件来控制播放状态 + playerView.play() + pipContentViewArray.append(playerView) + let ptr = Unmanaged.passUnretained(playerView).toOpaque() + let viewId = UInt64(UInt(bitPattern: ptr)) + result(NSNumber(value: viewId)) + + case "disposePipContentView": + guard let viewIdNum = call.arguments as? NSNumber else { + result(nil) + return + } + let targetId = viewIdNum.uint64Value + for (idx, playerView) in pipContentViewArray.enumerated() { + let currentId = UInt64(UInt(bitPattern: Unmanaged.passUnretained(playerView).toOpaque())) + if currentId == targetId { + playerView.pause() + pipContentViewArray.remove(at: idx) + break + } + } + result(nil) + + case "updatePipContentView": + guard let data = call.arguments as? [String: Any] else { + result(nil) + return + } + let lang = data["lang"] as? String + let cn = data["cn"] as? String + let en = data["en"] as? String + let viewIdNum = data["viewId"] as? NSNumber + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let viewIdNum = viewIdNum { + let targetId = viewIdNum.uint64Value + for playerView in self.pipContentViewArray { + let currentId = UInt64(UInt(bitPattern: Unmanaged.passUnretained(playerView).toOpaque())) + if currentId == targetId { + playerView.update(withLang: lang, cn: cn, en: en) + break + } + } + } else { + if let playerView = self.pipContentViewArray.last { + playerView.update(withLang: lang, cn: cn, en: en) + } + } + } + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.h b/local_plugins/native_plugin/ios/Classes/PlayerView.h deleted file mode 100644 index 50044ae41..000000000 --- a/local_plugins/native_plugin/ios/Classes/PlayerView.h +++ /dev/null @@ -1,15 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface PlayerView : UIView - -- (void)play; - -- (void)pause; - -- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en; - -@end - -NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.m b/local_plugins/native_plugin/ios/Classes/PlayerView.m deleted file mode 100644 index 1272aef4c..000000000 --- a/local_plugins/native_plugin/ios/Classes/PlayerView.m +++ /dev/null @@ -1,100 +0,0 @@ -#import "PlayerView.h" - -@interface PlayerView () - -@property(nonatomic, strong) UILabel *titleLabel; -@property(nonatomic, strong) UILabel *langLabel; -@property(nonatomic, strong) UILabel *cnLabel; -@property(nonatomic, strong) UILabel *enLabel; -@property(nonatomic, strong) UIStackView *stackView; - -@end - -@implementation PlayerView - -- (instancetype)init { - if (self = [super init]) { - self.backgroundColor = [UIColor whiteColor]; - - UILabel *title = [[UILabel alloc] init]; - title.text = @"实时翻译"; - title.font = [UIFont boldSystemFontOfSize:22]; - title.textColor = [UIColor blackColor]; - title.textAlignment = NSTextAlignmentCenter; - title.numberOfLines = 1; - self.titleLabel = title; - - UILabel *lang = [[UILabel alloc] init]; - lang.text = @"中文(简体) -> 英语"; - lang.font = [UIFont systemFontOfSize:14]; - lang.textColor = [UIColor darkGrayColor]; - lang.textAlignment = NSTextAlignmentLeft; - lang.numberOfLines = 1; - self.langLabel = lang; - - UILabel *cn = [[UILabel alloc] init]; - cn.font = [UIFont systemFontOfSize:18]; - cn.textColor = [UIColor blackColor]; - cn.textAlignment = NSTextAlignmentLeft; - cn.numberOfLines = 0; - self.cnLabel = cn; - - UILabel *en = [[UILabel alloc] init]; - en.font = [UIFont systemFontOfSize:18]; - en.textColor = [UIColor colorWithRed:0.24 green:0.54 blue:0.90 alpha:1.0]; - en.textAlignment = NSTextAlignmentLeft; - en.numberOfLines = 0; - self.enLabel = en; - - NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; - para.lineSpacing = 4; - - self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:@"正在识别..." attributes:@{NSParagraphStyleAttributeName: para}]; - - self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:@"翻译中..." attributes:@{NSParagraphStyleAttributeName: para}]; - - UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[self.titleLabel, self.langLabel, self.cnLabel, self.enLabel]]; - stack.axis = UILayoutConstraintAxisVertical; - stack.spacing = 12; - stack.alignment = UIStackViewAlignmentFill; - stack.distribution = UIStackViewDistributionFill; - stack.translatesAutoresizingMaskIntoConstraints = NO; - self.stackView = stack; - - self.layoutMargins = UIEdgeInsetsMake(16, 16, 16, 16); - [self addSubview:self.stackView]; - - UILayoutGuide *guide = self.layoutMarginsGuide; - [NSLayoutConstraint activateConstraints:@[ - [self.stackView.topAnchor constraintEqualToAnchor:guide.topAnchor], - [self.stackView.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor], - [self.stackView.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor], - [self.stackView.bottomAnchor constraintLessThanOrEqualToAnchor:guide.bottomAnchor] - ]]; - } - return self; -} - -- (void)play { - -} - -- (void)pause { - -} - -- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en { - if (lang != nil) { - self.langLabel.text = lang; - } - NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; - para.lineSpacing = 4; - if (cn != nil) { - self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:cn attributes:@{NSParagraphStyleAttributeName: para}]; - } - if (en != nil) { - self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:en attributes:@{NSParagraphStyleAttributeName: para}]; - } -} - -@end diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.swift b/local_plugins/native_plugin/ios/Classes/PlayerView.swift new file mode 100644 index 000000000..8b2b4cf77 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.swift @@ -0,0 +1,98 @@ +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/local_plugins/native_plugin/ios/floating_ui_plugin.podspec b/local_plugins/native_plugin/ios/floating_ui_plugin.podspec index c0a43118c..83083488d 100644 --- a/local_plugins/native_plugin/ios/floating_ui_plugin.podspec +++ b/local_plugins/native_plugin/ios/floating_ui_plugin.podspec @@ -14,9 +14,9 @@ A new Flutter plugin project. s.author = { 'Your Company' => 'email@example.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' s.platform = :ios, '12.0' + s.swift_version = '5.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } @@ -27,4 +27,3 @@ A new Flutter plugin project. # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files # s.resource_bundles = {'floating_ui_plugin_privacy' => ['Resources/PrivacyInfo.xcprivacy']} end - diff --git a/local_plugins/native_plugin/pubspec.yaml b/local_plugins/native_plugin/pubspec.yaml index e232a58df..db73f7be3 100644 --- a/local_plugins/native_plugin/pubspec.yaml +++ b/local_plugins/native_plugin/pubspec.yaml @@ -38,7 +38,7 @@ flutter: package: com.example.native_plugin pluginClass: NativePlugin ios: - pluginClass: NativePlugin + pluginClass: FloatingUiPlugin # To add assets to your plugin package, add an assets section, like this: # assets: