Browse Source

refactor(native_plugin): 重构iOS和Android原生插件代码

将iOS原生代码从Objective-C迁移到Swift,并重构Android代码为Kotlin
更新iOS插件配置和依赖,添加Swift版本支持
优化Android构建配置,添加Kotlin支持
实现新的浮动窗口服务和录音悬浮条功能
weicu
lxm 9 months ago
parent
commit
1a75267517
  1. 8
      local_plugins/native_plugin/android/build.gradle
  2. 405
      local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java
  3. 314
      local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java
  4. 292
      local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java
  5. 403
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt
  6. 254
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/NativePlugin.kt
  7. 297
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt
  8. 15
      local_plugins/native_plugin/ios/Classes/FLNativeView.h
  9. 83
      local_plugins/native_plugin/ios/Classes/FLNativeView.m
  10. 70
      local_plugins/native_plugin/ios/Classes/FLNativeView.swift
  11. 4
      local_plugins/native_plugin/ios/Classes/NativePlugin.h
  12. 114
      local_plugins/native_plugin/ios/Classes/NativePlugin.m
  13. 95
      local_plugins/native_plugin/ios/Classes/NativePlugin.swift
  14. 15
      local_plugins/native_plugin/ios/Classes/PlayerView.h
  15. 100
      local_plugins/native_plugin/ios/Classes/PlayerView.m
  16. 98
      local_plugins/native_plugin/ios/Classes/PlayerView.swift
  17. 3
      local_plugins/native_plugin/ios/floating_ui_plugin.podspec
  18. 2
      local_plugins/native_plugin/pubspec.yaml

8
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")
}

405
local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/FloatingWindowService.java

@ -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<String, Object> 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);
}
}

314
local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java

@ -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<String, Object> args = (Map<String, Object>) 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<String, Object> args = (Map<String, Object>) 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<String, Object> 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;
}
}

292
local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java

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

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

254
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<String>("duration")
val recordingType = call.argument<String>("recordingType")
val isPaused = call.argument<Boolean>("isPaused") ?: false
startRecordingFloatingService(
RecordingFloatingService.ACTION_SHOW,
duration,
recordingType,
isPaused
)
result.success(true)
}
"updateRecordingFloatingBar" -> {
val duration = call.argument<String>("duration")
val recordingType = call.argument<String>("recordingType")
val isPaused = call.argument<Boolean>("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<String, Any?>
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<String, Any?>
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<String, Any?>?): 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
}
}

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

15
local_plugins/native_plugin/ios/Classes/FLNativeView.h

@ -1,15 +0,0 @@
#import <flutter/flutter.h>
@interface FLNativeViewFactory : NSObject <FlutterPlatformViewFactory>
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messenger;
@end
@interface FLNativeView : NSObject <FlutterBinaryMessenger>
- (instancetype)initWithFrame:(CGRect)frame
viewIdentifier:(int64_t)viewId
arguments:(id _Nullable)args
binaryMessenger:(NSObject<FlutterBinaryMessenger> *)messenger;
- (UIView *)view;
@end

83
local_plugins/native_plugin/ios/Classes/FLNativeView.m

@ -1,83 +0,0 @@
#import "FLNativeView.h"
#import "PlayerView.h"
@implementation FLNativeViewFactory {
NSObject<FlutterBinaryMessenger> *_messenger;
}
- (instancetype)initWithMessenger:
(NSObject<FlutterBinaryMessenger> *)messenger {
self = [super init];
if (self) {
_messenger = messenger;
}
return self;
}
- (NSObject<FlutterPlatformView> *)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<FlutterMessageCodec> *)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<FlutterBinaryMessenger> *)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

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

4
local_plugins/native_plugin/ios/Classes/NativePlugin.h

@ -1,4 +0,0 @@
#import <Flutter/Flutter.h>
@interface NativePlugin : NSObject<FlutterPlugin>
@end

114
local_plugins/native_plugin/ios/Classes/NativePlugin.m

@ -1,114 +0,0 @@
#import "NativePlugin.h"
#import "FLNativeView.h"
#import "PlayerView.h"
#import <AVKit/AVKit.h>
@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<FlutterPluginRegistrar> *)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

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

15
local_plugins/native_plugin/ios/Classes/PlayerView.h

@ -1,15 +0,0 @@
#import <UIKit/UIKit.h>
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

100
local_plugins/native_plugin/ios/Classes/PlayerView.m

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

98
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])
}
}
}

3
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

2
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:

Loading…
Cancel
Save