Browse Source
将iOS原生代码从Objective-C迁移到Swift,并重构Android代码为Kotlin 更新iOS插件配置和依赖,添加Swift版本支持 优化Android构建配置,添加Kotlin支持 实现新的浮动窗口服务和录音悬浮条功能newdev_shunjiawei
18 changed files with 1227 additions and 1345 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
|
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
|
|||
@ -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 |
|||
@ -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 |
|||
@ -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)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -1,4 +0,0 @@ |
|||
#import <Flutter/Flutter.h> |
|||
|
|||
@interface NativePlugin : NSObject<FlutterPlugin> |
|||
@end |
|||
@ -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 |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue