Browse Source

Revert "feat(ios): 实现基于ReplayKit的全局悬浮窗功能"

This reverts commit 26ffe461a0.
weicu
lxm 1 year ago
parent
commit
65864772fc
  1. 10
      ios/BroadcastExtension/BroadcastExtension.entitlements
  2. 38
      ios/BroadcastExtension/Info.plist
  3. 216
      ios/BroadcastExtension/SampleHandler.swift
  4. 180
      ios/ReplayKit_Implementation_Summary.md
  5. 142
      ios/ReplayKit_Integration_Guide.md
  6. 10
      ios/Runner/AppDelegate.swift
  7. 341
      ios/Runner/FloatingWindow.swift
  8. 154
      ios/Runner/FloatingWindowPlugin.swift
  9. 40
      ios/Runner/OverlayPermissionPlugin.swift
  10. 247
      ios/Runner/ReplayKitManager.swift
  11. 4
      ios/Runner/Runner.entitlements
  12. 15
      lib/core/utils/overlay_permission_util.dart
  13. 149
      test/ios_floating_window_test.dart

10
ios/BroadcastExtension/BroadcastExtension.entitlements

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.saitong.voitrans.shared</string>
</array>
</dict>
</plist>

38
ios/BroadcastExtension/Info.plist

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Voitrans 字幕悬浮窗</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.broadcast-services-upload</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
<key>RPBroadcastProcessMode</key>
<string>RPBroadcastProcessModeSampleBuffer</string>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

216
ios/BroadcastExtension/SampleHandler.swift

@ -1,216 +0,0 @@
import ReplayKit
import UIKit
import AVFoundation
/// Broadcast Upload Extension 的核心处理类
/// 负责接收屏幕内容并叠加字幕悬浮窗
class SampleHandler: RPBroadcastSampleHandler {
// MARK: - 属性
private var overlayWindow: UIWindow?
private var subtitleLabel: UILabel?
private var containerView: UIView?
private let appGroupIdentifier = "group.com.saitong.voitrans.shared"
// MARK: - 生命周期方法
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
print("🎬 ReplayKit 广播开始")
setupOverlayWindow()
startMonitoringSubtitleUpdates()
}
override func broadcastPaused() {
print("⏸️ ReplayKit 广播暂停")
hideOverlay()
}
override func broadcastResumed() {
print("▶️ ReplayKit 广播恢复")
showOverlay()
}
override func broadcastFinished() {
print("🛑 ReplayKit 广播结束")
cleanupOverlay()
}
// MARK: - 样本处理方法
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
switch sampleBufferType {
case RPSampleBufferType.video:
// 处理视频帧 - 这里我们主要用于触发悬浮窗显示
break
case RPSampleBufferType.audioApp:
// 处理应用音频
break
case RPSampleBufferType.audioMic:
// 处理麦克风音频
break
@unknown default:
break
}
}
// MARK: - 悬浮窗设置
/// 设置悬浮窗口
private func setupOverlayWindow() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// 创建悬浮窗口
self.overlayWindow = UIWindow(frame: UIScreen.main.bounds)
self.overlayWindow?.windowLevel = UIWindow.Level.alert + 1000
self.overlayWindow?.backgroundColor = UIColor.clear
self.overlayWindow?.isHidden = false
// 创建容器视图
self.containerView = UIView()
self.containerView?.backgroundColor = UIColor.black.withAlphaComponent(0.8)
self.containerView?.layer.cornerRadius = 12
self.containerView?.layer.masksToBounds = true
self.containerView?.translatesAutoresizingMaskIntoConstraints = false
// 创建字幕标签
self.subtitleLabel = UILabel()
self.subtitleLabel?.textColor = .white
self.subtitleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
self.subtitleLabel?.numberOfLines = 0
self.subtitleLabel?.textAlignment = .center
self.subtitleLabel?.translatesAutoresizingMaskIntoConstraints = false
self.subtitleLabel?.text = "等待翻译内容..."
// 添加视图层次
guard let containerView = self.containerView,
let subtitleLabel = self.subtitleLabel,
let overlayWindow = self.overlayWindow else { return }
containerView.addSubview(subtitleLabel)
overlayWindow.addSubview(containerView)
// 设置约束
NSLayoutConstraint.activate([
// 容器视图约束 - 位于屏幕顶部
containerView.topAnchor.constraint(equalTo: overlayWindow.safeAreaLayoutGuide.topAnchor, constant: 20),
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: overlayWindow.leadingAnchor, constant: 20),
containerView.trailingAnchor.constraint(lessThanOrEqualTo: overlayWindow.trailingAnchor, constant: -20),
containerView.centerXAnchor.constraint(equalTo: overlayWindow.centerXAnchor),
containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 60),
// 字幕标签约束
subtitleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12),
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),
subtitleLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -12)
])
print("✅ 悬浮窗口设置完成")
}
}
/// 显示悬浮窗
private func showOverlay() {
DispatchQueue.main.async { [weak self] in
self?.overlayWindow?.isHidden = false
self?.overlayWindow?.alpha = 1.0
}
}
/// 隐藏悬浮窗
private func hideOverlay() {
DispatchQueue.main.async { [weak self] in
UIView.animate(withDuration: 0.3) {
self?.overlayWindow?.alpha = 0.0
} completion: { _ in
self?.overlayWindow?.isHidden = true
}
}
}
/// 清理悬浮窗
private func cleanupOverlay() {
DispatchQueue.main.async { [weak self] in
self?.overlayWindow?.isHidden = true
self?.overlayWindow = nil
self?.subtitleLabel = nil
self?.containerView = nil
}
}
// MARK: - 字幕更新监听
/// 开始监听字幕更新
private func startMonitoringSubtitleUpdates() {
// 使用 UserDefaults 监听字幕更新
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) {
// 设置初始值监听
userDefaults.addObserver(self, forKeyPath: "subtitle_text", options: [.new], context: nil)
userDefaults.addObserver(self, forKeyPath: "subtitle_source_language", options: [.new], context: nil)
userDefaults.addObserver(self, forKeyPath: "subtitle_target_language", options: [.new], context: nil)
print("✅ 开始监听字幕更新")
}
}
/// KVO 监听回调
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "subtitle_text" || keyPath == "subtitle_source_language" || keyPath == "subtitle_target_language" {
updateSubtitleDisplay()
}
}
/// 更新字幕显示
private func updateSubtitleDisplay() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
let subtitleText = userDefaults.string(forKey: "subtitle_text") ?? ""
let sourceLanguage = userDefaults.string(forKey: "subtitle_source_language") ?? ""
let targetLanguage = userDefaults.string(forKey: "subtitle_target_language") ?? ""
let isIntermediate = userDefaults.bool(forKey: "subtitle_is_intermediate")
DispatchQueue.main.async { [weak self] in
guard let self = self, let subtitleLabel = self.subtitleLabel else { return }
if subtitleText.isEmpty {
subtitleLabel.text = "等待翻译内容..."
subtitleLabel.alpha = 0.6
} else {
// 构建显示文本
var displayText = subtitleText
if !sourceLanguage.isEmpty && !targetLanguage.isEmpty {
displayText = "\(sourceLanguage) → \(targetLanguage)\n\(subtitleText)"
}
subtitleLabel.text = displayText
subtitleLabel.alpha = isIntermediate ? 0.8 : 1.0
// 添加淡入动画
UIView.animate(withDuration: 0.2) {
self.containerView?.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
} completion: { _ in
UIView.animate(withDuration: 0.2) {
self.containerView?.transform = .identity
}
}
}
print("📝 字幕更新: \(subtitleText)")
}
}
// MARK: - 清理
deinit {
// 移除 KVO 监听
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) {
userDefaults.removeObserver(self, forKeyPath: "subtitle_text")
userDefaults.removeObserver(self, forKeyPath: "subtitle_source_language")
userDefaults.removeObserver(self, forKeyPath: "subtitle_target_language")
}
cleanupOverlay()
print("🗑️ SampleHandler 已清理")
}
}

180
ios/ReplayKit_Implementation_Summary.md

@ -1,180 +0,0 @@
# iOS ReplayKit 悬浮窗实现总结
## 🎯 实现目标
✅ **已完成**:使用 ReplayKit + Broadcast Upload Extension 实现 iOS 全局字幕悬浮窗功能,与 Android 版本保持功能一致。
## 📁 文件结构
### 主应用文件
```
ios/Runner/
├── FloatingWindowPlugin.swift # Flutter 平台通道插件
├── ReplayKitManager.swift # ReplayKit 管理器
├── OverlayPermissionPlugin.swift # 权限管理插件(兼容性)
├── AppDelegate.swift # 应用委托(已更新)
└── Runner.entitlements # 主应用权限配置
```
### Extension 文件
```
ios/BroadcastExtension/
├── SampleHandler.swift # 广播处理器(核心实现)
├── Info.plist # Extension 配置
└── BroadcastExtension.entitlements # Extension 权限配置
```
### 配置和文档
```
ios/
├── ReplayKit_Integration_Guide.md # Xcode 集成指南
└── ReplayKit_Implementation_Summary.md # 实现总结(本文件)
```
## 🔧 核心技术实现
### 1. ReplayKit 广播控制 (`ReplayKitManager.swift`)
- **功能**:管理屏幕广播的启动、停止和状态监控
- **关键方法**:
- `startBroadcast()`: 启动 ReplayKit 广播
- `stopBroadcast()`: 停止广播
- `updateSubtitle()`: 更新字幕内容
- **数据通信**:使用 App Groups 与 Extension 共享数据
### 2. 广播处理器 (`SampleHandler.swift`)
- **功能**:接收屏幕内容并显示悬浮窗字幕
- **关键特性**:
- 创建全局悬浮窗口 (`UIWindow`)
- 实时监听字幕更新 (KVO + UserDefaults)
- 自适应字幕显示和动画效果
- **显示位置**:屏幕顶部中央,半透明黑色背景
### 3. Flutter 接口 (`FloatingWindowPlugin.swift`)
- **功能**:提供与 Android 一致的 Flutter 调用接口
- **方法映射**:
- `enableFloatingWindow` → 启动 ReplayKit 广播
- `disableFloatingWindow` → 停止广播
- `updateContent` → 更新字幕内容
- `setPosition` → 兼容性方法(位置固定)
## 📱 用户体验流程
### 启动流程
1. 用户在翻译界面点击"悬浮窗"按钮
2. 系统弹出"开始直播屏幕"选择界面
3. 用户选择 "Voitrans 字幕悬浮窗" 选项
4. 系统开始屏幕录制,状态栏显示红色录制指示器
5. 悬浮窗出现在屏幕顶部,显示"等待翻译内容..."
### 使用流程
1. 应用进行语音识别和翻译
2. 实时更新悬浮窗中的字幕内容
3. 支持源语言和目标语言显示
4. 中间结果和最终结果有不同的透明度显示
5. 用户可以在任何应用中看到翻译字幕
### 停止流程
1. 用户再次点击"悬浮窗"按钮或停止翻译
2. 应用调用停止广播方法
3. 悬浮窗消失,录制指示器消失
## 🔐 权限和配置
### App Groups 配置
- **标识符**:`group.com.saitong.voitrans.shared`
- **用途**:主应用与 Extension 之间的数据共享
- **数据键**:
- `subtitle_text`: 字幕文本
- `subtitle_source_language`: 源语言
- `subtitle_target_language`: 目标语言
- `subtitle_is_intermediate`: 是否为中间结果
### 系统权限
- **屏幕录制权限**:用户首次使用时系统自动请求
- **麦克风权限**:如果需要录制音频(可选)
- **摄像头权限**:ReplayKit 框架要求(实际不使用)
## 🎨 UI 设计特点
### 悬浮窗样式
- **位置**:屏幕顶部中央,距离安全区域 20pt
- **背景**:黑色半透明 (alpha: 0.8)
- **圆角**:12pt 圆角矩形
- **字体**:系统字体 16pt,白色文字
- **布局**:垂直布局,支持多行文本
### 动画效果
- **出现动画**:淡入效果 (0.3s)
- **更新动画**:轻微缩放效果 (1.05x → 1.0x)
- **状态指示**:中间结果透明度 0.8,最终结果透明度 1.0
## 🔄 与 Android 版本对比
### 功能一致性
| 功能 | Android | iOS (ReplayKit) | 状态 |
|------|---------|-----------------|------|
| 全局悬浮窗 | ✅ 系统悬浮窗 | ✅ ReplayKit 悬浮窗 | 一致 |
| 实时字幕更新 | ✅ | ✅ | 一致 |
| 拖拽移动 | ✅ | ❌ 位置固定 | 差异 |
| 最小化/展开 | ✅ | ❌ 固定样式 | 差异 |
| 权限管理 | ✅ 悬浮窗权限 | ✅ 录制权限 | 一致 |
### Flutter 接口一致性
- ✅ 所有方法签名保持一致
- ✅ 回调事件保持一致
- ✅ 数据模型保持一致
- ✅ 错误处理保持一致
## 🚀 部署和发布
### 开发环境要求
- Xcode 14.0+
- iOS 16.0+ (ReplayKit 2 要求)
- 有效的 Apple Developer 账号
- App Groups 权限配置
### App Store 审核要点
- ✅ 使用官方 ReplayKit API,符合审核规范
- ✅ 明确的用户权限请求和说明
- ✅ 合理的功能用途(翻译字幕显示)
- ✅ 不涉及隐私数据收集
### 发布注意事项
1. 确保 Extension 的 Bundle ID 正确配置
2. App Groups 在 Developer Portal 中正确设置
3. 两个 target 使用相同的签名证书
4. 测试在不同 iOS 版本上的兼容性
## 📊 技术优势
### 相比应用内悬浮窗
- ✅ 真正的全局显示能力
- ✅ 符合 iOS 平台规范
- ✅ 通过 App Store 审核
- ✅ 系统级权限管理
### 相比其他方案
- ✅ 无需越狱或私有 API
- ✅ 稳定可靠的系统支持
- ✅ 良好的用户体验
- ✅ 与现有架构无缝集成
## 🔮 未来扩展
### 可能的改进
1. **自定义样式**:支持更多字幕样式选项
2. **位置调整**:探索在录制流中实现位置调整
3. **多语言支持**:优化多语言字幕显示
4. **性能优化**:减少内存占用和电池消耗
### 技术演进
- 关注 iOS 新版本的 ReplayKit 功能更新
- 考虑集成 Live Activities (iOS 16+) 作为补充方案
- 探索 WidgetKit 在锁屏界面的字幕显示
---
**实现状态**: ✅ 完成
**测试状态**: ✅ 基础功能测试通过
**文档状态**: ✅ 完整的集成和使用文档
**发布准备**: ⏳ 需要 Xcode 项目配置

142
ios/ReplayKit_Integration_Guide.md

@ -1,142 +0,0 @@
# iOS ReplayKit 悬浮窗集成指南
## 📋 概述
本指南说明如何在 Xcode 中配置 ReplayKit Broadcast Upload Extension 以实现全局字幕悬浮窗功能。
## 🔧 Xcode 项目配置步骤
### 1. 添加 Broadcast Upload Extension Target
1. 在 Xcode 中打开 `ios/Runner.xcworkspace`
2. 选择项目根节点 → 点击 "+" 添加新 Target
3. 选择 "Broadcast Upload Extension"
4. 配置信息:
- Product Name: `BroadcastExtension`
- Bundle Identifier: `com.saitong.voitrans.BroadcastExtension`
- Language: Swift
- 确保 "Include UI Extension" 未选中
### 2. 配置 Extension Target
#### 2.1 替换默认文件
- 删除自动生成的 `SampleHandler.swift`
- 将项目中的 `ios/BroadcastExtension/SampleHandler.swift` 复制到 Extension target
- 将项目中的 `ios/BroadcastExtension/Info.plist` 替换 Extension 的 Info.plist
#### 2.2 添加 Entitlements
- 在 Extension target 的 Build Settings 中设置 Code Signing Entitlements
- 指向 `ios/BroadcastExtension/BroadcastExtension.entitlements`
#### 2.3 配置 Build Settings
- iOS Deployment Target: 16.0 (与主应用保持一致)
- Swift Language Version: Swift 5
- Enable Bitcode: No
### 3. 配置 App Groups
#### 3.1 在 Apple Developer Portal 中:
1. 创建 App Group: `group.com.saitong.voitrans.shared`
2. 将主应用和 Extension 都添加到此 App Group
#### 3.2 在 Xcode 中:
1. 主应用 Target → Signing & Capabilities → 添加 "App Groups" capability
2. Extension Target → Signing & Capabilities → 添加 "App Groups" capability
3. 两个 target 都勾选 `group.com.saitong.voitrans.shared`
### 4. 更新主应用配置
#### 4.1 添加 ReplayKit 框架
- 主应用 Target → Build Phases → Link Binary With Libraries
- 添加 `ReplayKit.framework`
#### 4.2 更新 Info.plist
在主应用的 Info.plist 中添加:
```xml
<key>NSCameraUsageDescription</key>
<string>需要摄像头权限以支持屏幕录制功能</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要麦克风权限以支持音频录制功能</string>
```
## 📱 使用说明
### Flutter 调用方式
```dart
// 启动悬浮窗(会弹出系统录屏选择界面)
await floatingWindowManager.enableFloatingWindow();
// 更新字幕内容
await floatingWindowManager.updateFloatingWindowContent(
FloatingWindowData(
sourceText: '你好',
translatedText: 'Hello',
sourceLanguage: '中文',
targetLanguage: '英语',
timestamp: DateTime.now(),
isIntermediate: false,
),
);
// 停止悬浮窗
await floatingWindowManager.disableFloatingWindow();
```
### 用户操作流程
1. 用户点击"启用悬浮窗"按钮
2. 系统弹出"开始直播屏幕"选择界面
3. 用户选择 "Voitrans 字幕悬浮窗" 选项
4. 系统开始屏幕录制,悬浮窗显示在屏幕顶部
5. 应用实时更新字幕内容
6. 用户可以在任何应用中看到翻译字幕
## ⚠️ 注意事项
### 开发阶段
- 确保两个 target 使用相同的开发者账号签名
- App Groups 必须在 Apple Developer Portal 中正确配置
- Extension 的 Bundle ID 必须是主应用的子域名
### 发布阶段
- Extension 会随主应用一起打包发布
- 用户首次使用时需要授权屏幕录制权限
- 符合 App Store 审核规范,因为使用的是官方 ReplayKit API
### 用户体验
- 录制期间状态栏会显示红色录制指示器
- 悬浮窗位置固定在屏幕顶部中央
- 支持实时字幕更新和语言切换
- 可以通过停止录制来关闭悬浮窗
## 🔍 故障排除
### 常见问题
1. **Extension 无法启动**
- 检查 Bundle ID 配置
- 确认 App Groups 权限配置正确
- 验证代码签名设置
2. **字幕不更新**
- 检查 App Groups 数据同步
- 确认 UserDefaults 键名一致
- 查看 Extension 日志输出
3. **编译错误**
- 确保 iOS Deployment Target 一致
- 检查 Swift 版本设置
- 验证框架依赖配置
### 调试方法
1. 使用 Xcode 的 Device Console 查看 Extension 日志
2. 在 SampleHandler 中添加 print 语句进行调试
3. 检查 App Groups 中的数据是否正确写入
## 📚 相关文档
- [Apple ReplayKit Documentation](https://developer.apple.com/documentation/replaykit)
- [Broadcast Upload Extension Guide](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler)
- [App Groups Documentation](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups)

10
ios/Runner/AppDelegate.swift

@ -8,16 +8,6 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// 注册悬浮窗相关插件
if let registrar = self.registrar(forPlugin: "FloatingWindowPlugin") {
FloatingWindowPlugin.register(with: registrar)
}
if let registrar = self.registrar(forPlugin: "OverlayPermissionPlugin") {
OverlayPermissionPlugin.register(with: registrar)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

341
ios/Runner/FloatingWindow.swift

@ -1,341 +0,0 @@
import UIKit
/// 悬浮窗实现类
/// 使用 UIWindow 创建应用内悬浮窗口
class FloatingWindow: UIWindow {
// MARK: - 属性
private weak var delegate: FloatingWindowDelegate?
private var floatingViewController: FloatingViewController?
private var isMinimized = true
private var lastPosition = CGPoint(x: 100, y: 100)
// MARK: - 初始化
init(delegate: FloatingWindowDelegate) {
self.delegate = delegate
// 创建窗口
super.init(frame: CGRect(x: 100, y: 100, width: 300, height: 120))
setupWindow()
setupViewController()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - 私有方法
/// 设置窗口属性
private func setupWindow() {
// 设置窗口层级,确保在最顶层显示
windowLevel = UIWindow.Level.alert + 1
backgroundColor = UIColor.clear
// 设置窗口为可见但不成为主窗口
isHidden = true
// 设置圆角
layer.cornerRadius = 12
layer.masksToBounds = true
}
/// 设置视图控制器
private func setupViewController() {
floatingViewController = FloatingViewController()
floatingViewController?.delegate = self
rootViewController = floatingViewController
}
// MARK: - 公共方法
/// 显示悬浮窗
func show() {
isHidden = false
makeKeyAndVisible()
// 添加显示动画
alpha = 0
UIView.animate(withDuration: 0.3) {
self.alpha = 1
}
delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded")
}
/// 隐藏悬浮窗
func hide() {
UIView.animate(withDuration: 0.3, animations: {
self.alpha = 0
}) { _ in
self.isHidden = true
self.resignKey()
}
delegate?.floatingWindowDidChangeState("hidden")
}
/// 更新内容
func updateContent(_ data: FloatingWindowContentData) {
floatingViewController?.updateContent(data)
}
/// 设置位置
func setPosition(x: Double, y: Double) {
let newPosition = CGPoint(x: x, y: y)
lastPosition = newPosition
UIView.animate(withDuration: 0.3) {
self.frame.origin = newPosition
}
}
/// 切换最小化/展开状态
func toggleMinimized() {
isMinimized.toggle()
let newSize: CGSize
if isMinimized {
newSize = CGSize(width: 120, height: 60)
} else {
newSize = CGSize(width: 300, height: 120)
}
UIView.animate(withDuration: 0.3) {
self.frame.size = newSize
}
floatingViewController?.setMinimized(isMinimized)
delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded")
}
}
// MARK: - FloatingViewControllerDelegate
extension FloatingWindow: FloatingViewControllerDelegate {
func floatingViewControllerDidTap() {
toggleMinimized()
delegate?.floatingWindowDidClick()
}
func floatingViewControllerDidPan(translation: CGPoint) {
let newOrigin = CGPoint(
x: lastPosition.x + translation.x,
y: lastPosition.y + translation.y
)
// 限制在屏幕范围内
let screenBounds = UIScreen.main.bounds
let constrainedX = max(0, min(newOrigin.x, screenBounds.width - frame.width))
let constrainedY = max(0, min(newOrigin.y, screenBounds.height - frame.height))
frame.origin = CGPoint(x: constrainedX, y: constrainedY)
}
func floatingViewControllerDidEndPan() {
lastPosition = frame.origin
}
func floatingViewControllerDidRequestClose() {
hide()
delegate?.floatingWindowDidClose()
}
}
/// 悬浮窗视图控制器
private class FloatingViewController: UIViewController {
// MARK: - 属性
weak var delegate: FloatingViewControllerDelegate?
// UI 组件
private let containerView = UIView()
private let sourceTextLabel = UILabel()
private let translatedTextLabel = UILabel()
private let languageInfoLabel = UILabel()
private let timestampLabel = UILabel()
private let closeButton = UIButton(type: .system)
// 手势识别器
private var panGestureRecognizer: UIPanGestureRecognizer!
private var tapGestureRecognizer: UITapGestureRecognizer!
private var isMinimized = true
// MARK: - 生命周期
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupGestures()
}
// MARK: - 私有方法
/// 设置 UI
private func setupUI() {
view.backgroundColor = UIColor.clear
// 容器视图
containerView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
containerView.layer.cornerRadius = 12
containerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(containerView)
// 源文本标签
sourceTextLabel.textColor = .white
sourceTextLabel.font = UIFont.systemFont(ofSize: 14)
sourceTextLabel.numberOfLines = 2
sourceTextLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(sourceTextLabel)
// 翻译文本标签
translatedTextLabel.textColor = .systemBlue
translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium)
translatedTextLabel.numberOfLines = 2
translatedTextLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(translatedTextLabel)
// 语言信息标签
languageInfoLabel.textColor = .lightGray
languageInfoLabel.font = UIFont.systemFont(ofSize: 10)
languageInfoLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(languageInfoLabel)
// 时间戳标签
timestampLabel.textColor = .lightGray
timestampLabel.font = UIFont.systemFont(ofSize: 10)
timestampLabel.textAlignment = .right
timestampLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(timestampLabel)
// 关闭按钮
closeButton.setTitle("×", for: .normal)
closeButton.setTitleColor(.white, for: .normal)
closeButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .bold)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
containerView.addSubview(closeButton)
setupConstraints()
}
/// 设置约束
private func setupConstraints() {
NSLayoutConstraint.activate([
// 容器视图
containerView.topAnchor.constraint(equalTo: view.topAnchor),
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// 关闭按钮
closeButton.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4),
closeButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -4),
closeButton.widthAnchor.constraint(equalToConstant: 20),
closeButton.heightAnchor.constraint(equalToConstant: 20),
// 源文本标签
sourceTextLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8),
sourceTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
sourceTextLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -4),
// 翻译文本标签
translatedTextLabel.topAnchor.constraint(equalTo: sourceTextLabel.bottomAnchor, constant: 4),
translatedTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
translatedTextLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
// 语言信息标签
languageInfoLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4),
languageInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
languageInfoLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4),
// 时间戳标签
timestampLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4),
timestampLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
timestampLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4)
])
}
/// 设置手势识别器
private func setupGestures() {
// 拖拽手势
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
view.addGestureRecognizer(panGestureRecognizer)
// 点击手势
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
view.addGestureRecognizer(tapGestureRecognizer)
}
// MARK: - 手势处理
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view.superview)
switch gesture.state {
case .changed:
delegate?.floatingViewControllerDidPan(translation: translation)
case .ended, .cancelled:
delegate?.floatingViewControllerDidEndPan()
default:
break
}
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
delegate?.floatingViewControllerDidTap()
}
@objc private func closeButtonTapped() {
delegate?.floatingViewControllerDidRequestClose()
}
// MARK: - 公共方法
/// 更新内容
func updateContent(_ data: FloatingWindowContentData) {
sourceTextLabel.text = data.sourceText.isEmpty ? "正在识别..." : data.sourceText
translatedTextLabel.text = data.translatedText.isEmpty ? "翻译中..." : data.translatedText
languageInfoLabel.text = "\(data.sourceLanguage) → \(data.targetLanguage)"
// 格式化时间戳
if data.timestamp > 0 {
let date = Date(timeIntervalSince1970: TimeInterval(data.timestamp / 1000))
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
timestampLabel.text = formatter.string(from: date)
}
// 中间结果的视觉提示
translatedTextLabel.alpha = data.isIntermediate ? 0.7 : 1.0
}
/// 设置最小化状态
func setMinimized(_ minimized: Bool) {
isMinimized = minimized
// 在最小化状态下隐藏部分元素
sourceTextLabel.isHidden = minimized
languageInfoLabel.isHidden = minimized
timestampLabel.isHidden = minimized
closeButton.isHidden = minimized
// 调整翻译文本标签的约束
if minimized {
translatedTextLabel.numberOfLines = 1
translatedTextLabel.font = UIFont.systemFont(ofSize: 12, weight: .medium)
} else {
translatedTextLabel.numberOfLines = 2
translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium)
}
}
}
/// 悬浮窗视图控制器代理协议
private protocol FloatingViewControllerDelegate: AnyObject {
func floatingViewControllerDidTap()
func floatingViewControllerDidPan(translation: CGPoint)
func floatingViewControllerDidEndPan()
func floatingViewControllerDidRequestClose()
}

154
ios/Runner/FloatingWindowPlugin.swift

@ -1,154 +0,0 @@
import Flutter
import UIKit
/// iOS 悬浮窗插件 - ReplayKit 方案
/// 使用 ReplayKit + Broadcast Upload Extension 实现全局字幕悬浮窗
public class FloatingWindowPlugin: NSObject, FlutterPlugin {
// MARK: - 常量
private static let channelName = "floating_window"
// MARK: - 属性
private var methodChannel: FlutterMethodChannel?
private let replayKitManager = ReplayKitManager.shared
// MARK: - 插件注册
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = FloatingWindowPlugin()
// 创建方法通道
let methodChannel = FlutterMethodChannel(
name: channelName,
binaryMessenger: registrar.messenger()
)
instance.methodChannel = methodChannel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
// 设置 ReplayKit 回调
instance.setupReplayKitCallbacks()
print("FloatingWindowPlugin (ReplayKit) 注册成功")
}
// MARK: - 方法调用处理
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "enableFloatingWindow":
enableFloatingWindow(result: result)
case "disableFloatingWindow":
disableFloatingWindow(result: result)
case "showFloatingWindow":
showFloatingWindow(result: result)
case "hideFloatingWindow":
hideFloatingWindow(result: result)
case "updateContent":
if let arguments = call.arguments as? [String: Any] {
updateContent(arguments: arguments, result: result)
} else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的参数", details: nil))
}
case "setPosition":
// ReplayKit 方案中位置固定,但保持接口兼容性
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - ReplayKit 回调设置
/// 设置 ReplayKit 回调
private func setupReplayKitCallbacks() {
replayKitManager.onBroadcastStarted = { [weak self] in
self?.methodChannel?.invokeMethod("onFloatingWindowStateChanged", arguments: ["state": "minimized"])
}
replayKitManager.onBroadcastFinished = { [weak self] in
self?.methodChannel?.invokeMethod("onFloatingWindowClosed", arguments: nil)
}
replayKitManager.onBroadcastError = { [weak self] error in
self?.methodChannel?.invokeMethod("onFloatingWindowError", arguments: ["error": error.localizedDescription])
}
}
// MARK: - 悬浮窗控制方法
/// 启用悬浮窗功能(启动 ReplayKit 广播)
private func enableFloatingWindow(result: @escaping FlutterResult) {
replayKitManager.startBroadcast { success, error in
if success {
result(true)
} else {
result(FlutterError(
code: "ENABLE_ERROR",
message: "启用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")",
details: nil
))
}
}
}
/// 禁用悬浮窗功能(停止 ReplayKit 广播)
private func disableFloatingWindow(result: @escaping FlutterResult) {
replayKitManager.stopBroadcast { success, error in
if success {
result(true)
} else {
result(FlutterError(
code: "DISABLE_ERROR",
message: "禁用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")",
details: nil
))
}
}
}
/// 显示悬浮窗(ReplayKit 方案中自动显示)
private func showFloatingWindow(result: @escaping FlutterResult) {
if replayKitManager.isBroadcasting {
result(true)
} else {
result(FlutterError(code: "NOT_BROADCASTING", message: "广播未启动", details: nil))
}
}
/// 隐藏悬浮窗(ReplayKit 方案中通过停止广播实现)
private func hideFloatingWindow(result: @escaping FlutterResult) {
disableFloatingWindow(result: result)
}
/// 更新悬浮窗内容
private func updateContent(arguments: [String: Any], result: @escaping FlutterResult) {
let sourceText = arguments["sourceText"] as? String ?? ""
let translatedText = arguments["translatedText"] as? String ?? ""
let sourceLanguage = arguments["sourceLanguage"] as? String ?? ""
let targetLanguage = arguments["targetLanguage"] as? String ?? ""
let isIntermediate = arguments["isIntermediate"] as? Bool ?? false
// 构建显示文本
var displayText = ""
if !sourceText.isEmpty && !translatedText.isEmpty {
displayText = "\(sourceText)\n\(translatedText)"
} else if !translatedText.isEmpty {
displayText = translatedText
} else if !sourceText.isEmpty {
displayText = sourceText
}
replayKitManager.updateSubtitle(
text: displayText,
sourceLanguage: sourceLanguage,
targetLanguage: targetLanguage,
isIntermediate: isIntermediate
)
result(true)
}

40
ios/Runner/OverlayPermissionPlugin.swift

@ -1,40 +0,0 @@
import Flutter
import UIKit
/// iOS 悬浮窗权限插件
/// 为了与 Android 版本保持接口一致性而创建
public class OverlayPermissionPlugin: NSObject, FlutterPlugin {
// MARK: - 常量
private static let channelName = "overlay_permission"
// MARK: - 插件注册
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = OverlayPermissionPlugin()
// 创建方法通道
let methodChannel = FlutterMethodChannel(
name: channelName,
binaryMessenger: registrar.messenger()
)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
print("OverlayPermissionPlugin 注册成功")
}
// MARK: - 方法调用处理
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "hasOverlayPermission":
// iOS 使用应用内悬浮窗,不需要特殊权限
result(true)
case "requestOverlayPermission":
// iOS 使用应用内悬浮窗,不需要特殊权限
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
}

247
ios/Runner/ReplayKitManager.swift

@ -1,247 +0,0 @@
import ReplayKit
import UIKit
/// ReplayKit 管理器
/// 负责管理屏幕广播的启动、停止和状态监控
class ReplayKitManager: NSObject {
// MARK: - 单例
static let shared = ReplayKitManager()
// MARK: - 属性
private var broadcastController: RPBroadcastController?
private let appGroupIdentifier = "group.com.saitong.voitrans.shared"
private var isRecording = false
// 回调闭包
var onBroadcastStarted: (() -> Void)?
var onBroadcastFinished: (() -> Void)?
var onBroadcastError: ((Error) -> Void)?
// MARK: - 初始化
private override init() {
super.init()
setupNotifications()
}
// MARK: - 公共方法
/// 启动屏幕广播
/// - Parameter completion: 完成回调
func startBroadcast(completion: @escaping (Bool, Error?) -> Void) {
print("🎬 准备启动 ReplayKit 广播")
// 检查是否已经在录制
if isRecording {
print("⚠️ 广播已在进行中")
completion(false, NSError(domain: "ReplayKitManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "广播已在进行中"]))
return
}
// 获取广播选择器
RPBroadcastActivityViewController.load { [weak self] (broadcastActivityViewController, error) in
DispatchQueue.main.async {
guard let self = self else { return }
if let error = error {
print("❌ 加载广播选择器失败: \(error.localizedDescription)")
completion(false, error)
return
}
guard let broadcastActivityViewController = broadcastActivityViewController else {
let error = NSError(domain: "ReplayKitManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "无法加载广播选择器"])
completion(false, error)
return
}
// 设置代理
broadcastActivityViewController.delegate = self
// 显示广播选择器
if let topViewController = self.getTopViewController() {
broadcastActivityViewController.modalPresentationStyle = .formSheet
topViewController.present(broadcastActivityViewController, animated: true) {
print("✅ 广播选择器已显示")
completion(true, nil)
}
} else {
let error = NSError(domain: "ReplayKitManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "无法获取顶层视图控制器"])
completion(false, error)
}
}
}
}
/// 停止屏幕广播
/// - Parameter completion: 完成回调
func stopBroadcast(completion: @escaping (Bool, Error?) -> Void) {
print("🛑 准备停止 ReplayKit 广播")
guard isRecording else {
print("⚠️ 当前没有进行中的广播")
completion(false, NSError(domain: "ReplayKitManager", code: -4, userInfo: [NSLocalizedDescriptionKey: "当前没有进行中的广播"]))
return
}
broadcastController?.finishBroadcast { [weak self] error in
DispatchQueue.main.async {
if let error = error {
print("❌ 停止广播失败: \(error.localizedDescription)")
completion(false, error)
} else {
print("✅ 广播已停止")
self?.isRecording = false
self?.broadcastController = nil
completion(true, nil)
}
}
}
}
/// 更新字幕内容
/// - Parameters:
/// - text: 字幕文本
/// - sourceLanguage: 源语言
/// - targetLanguage: 目标语言
/// - isIntermediate: 是否为中间结果
func updateSubtitle(text: String, sourceLanguage: String = "", targetLanguage: String = "", isIntermediate: Bool = false) {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("❌ 无法访问 App Group UserDefaults")
return
}
// 更新字幕数据
userDefaults.set(text, forKey: "subtitle_text")
userDefaults.set(sourceLanguage, forKey: "subtitle_source_language")
userDefaults.set(targetLanguage, forKey: "subtitle_target_language")
userDefaults.set(isIntermediate, forKey: "subtitle_is_intermediate")
userDefaults.set(Date().timeIntervalSince1970, forKey: "subtitle_timestamp")
// 强制同步
userDefaults.synchronize()
print("📝 字幕已更新: \(text)")
}
/// 检查广播状态
var isBroadcasting: Bool {
return isRecording
}
// MARK: - 私有方法
/// 设置通知监听
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
/// 应用进入后台
@objc private func applicationDidEnterBackground() {
print("📱 应用进入后台,广播状态: \(isRecording)")
}
/// 应用即将进入前台
@objc private func applicationWillEnterForeground() {
print("📱 应用即将进入前台,广播状态: \(isRecording)")
}
/// 获取顶层视图控制器
private func getTopViewController() -> UIViewController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else {
return nil
}
var topViewController = window.rootViewController
while let presentedViewController = topViewController?.presentedViewController {
topViewController = presentedViewController
}
return topViewController
}
// MARK: - 清理
deinit {
NotificationCenter.default.removeObserver(self)
print("🗑️ ReplayKitManager 已清理")
}
}
// MARK: - RPBroadcastActivityViewControllerDelegate
extension ReplayKitManager: RPBroadcastActivityViewControllerDelegate {
func broadcastActivityViewController(_ broadcastActivityViewController: RPBroadcastActivityViewController, didFinishWith broadcastController: RPBroadcastController?, error: Error?) {
DispatchQueue.main.async { [weak self] in
// 关闭选择器
broadcastActivityViewController.dismiss(animated: true)
if let error = error {
print("❌ 广播设置失败: \(error.localizedDescription)")
self?.onBroadcastError?(error)
return
}
guard let broadcastController = broadcastController else {
print("❌ 无法获取广播控制器")
let error = NSError(domain: "ReplayKitManager", code: -5, userInfo: [NSLocalizedDescriptionKey: "无法获取广播控制器"])
self?.onBroadcastError?(error)
return
}
// 保存广播控制器
self?.broadcastController = broadcastController
broadcastController.delegate = self
// 开始广播
broadcastController.startBroadcast { error in
DispatchQueue.main.async {
if let error = error {
print("❌ 开始广播失败: \(error.localizedDescription)")
self?.onBroadcastError?(error)
} else {
print("✅ 广播已开始")
self?.isRecording = true
self?.onBroadcastStarted?()
}
}
}
}
}
}
// MARK: - RPBroadcastControllerDelegate
extension ReplayKitManager: RPBroadcastControllerDelegate {
func broadcastController(_ broadcastController: RPBroadcastController, didFinishWithError error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.isRecording = false
self?.broadcastController = nil
if let error = error {
print("❌ 广播结束时发生错误: \(error.localizedDescription)")
self?.onBroadcastError?(error)
} else {
print("✅ 广播正常结束")
self?.onBroadcastFinished?()
}
}
}
func broadcastController(_ broadcastController: RPBroadcastController, didUpdateServiceInfo serviceInfo: [String : NSCoding & NSObjectProtocol]) {
print("📊 广播服务信息更新: \(serviceInfo)")
}
}

4
ios/Runner/Runner.entitlements

@ -30,9 +30,5 @@
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.saitong.voitrans.shared</string>
</array>
</dict>
</plist>

15
lib/core/utils/overlay_permission_util.dart

@ -19,13 +19,9 @@ class OverlayPermissionUtil {
Logger.error('检查悬浮窗权限失败: $e');
return false;
}
} else if (Platform.isIOS) {
// iOS 使用应用内悬浮窗,不需要特殊权限
Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限');
return true;
}
// 其他平台不支持
return false;
// iOS不需要特殊权限
return true;
}
/// 请求悬浮窗权限
@ -68,12 +64,7 @@ class OverlayPermissionUtil {
}
}
return false;
} else if (Platform.isIOS) {
// iOS 使用应用内悬浮窗,不需要特殊权限,但需要检查应用状态
Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限');
return true;
}
// 其他平台不支持
return false;
return true;
}
}

149
test/ios_floating_window_test.dart

@ -1,149 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
import 'package:deep_voice/core/services/floating_window_manager.dart';
/// iOS ReplayKit 悬浮窗功能测试
void main() {
group('iOS ReplayKit 悬浮窗测试', () {
late FloatingWindowManager floatingWindowManager;
late List<MethodCall> methodCalls;
setUp(() {
methodCalls = [];
floatingWindowManager = FloatingWindowManager();
// 模拟 iOS 平台的方法通道调用
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('floating_window'),
(MethodCall methodCall) async {
methodCalls.add(methodCall);
switch (methodCall.method) {
case 'enableFloatingWindow':
return true;
case 'disableFloatingWindow':
return true;
case 'showFloatingWindow':
return true;
case 'hideFloatingWindow':
return true;
case 'updateContent':
return true;
case 'setPosition':
return true;
default:
return null;
}
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('floating_window'),
null,
);
});
test('启用悬浮窗功能 (ReplayKit)', () async {
final result = await floatingWindowManager.enableFloatingWindow();
expect(result, isTrue);
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('enableFloatingWindow'));
});
test('禁用悬浮窗功能', () async {
await floatingWindowManager.disableFloatingWindow();
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('disableFloatingWindow'));
});
test('显示悬浮窗', () async {
await floatingWindowManager.showFloatingWindow();
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('showFloatingWindow'));
});
test('隐藏悬浮窗', () async {
await floatingWindowManager.hideFloatingWindow();
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('hideFloatingWindow'));
});
test('更新悬浮窗内容', () async {
final testData = FloatingWindowData(
sourceText: '你好',
translatedText: 'Hello',
sourceLanguage: '中文',
targetLanguage: '英语',
timestamp: DateTime.now(),
isIntermediate: false,
);
await floatingWindowManager.updateFloatingWindowContent(testData);
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('updateContent'));
final arguments = methodCalls[0].arguments as Map<String, dynamic>;
expect(arguments['sourceText'], equals('你好'));
expect(arguments['translatedText'], equals('Hello'));
expect(arguments['sourceLanguage'], equals('中文'));
expect(arguments['targetLanguage'], equals('英语'));
expect(arguments['isIntermediate'], equals(false));
});
test('设置悬浮窗位置', () async {
await floatingWindowManager.setFloatingWindowPosition(100.0, 200.0);
expect(methodCalls.length, equals(1));
expect(methodCalls[0].method, equals('setPosition'));
final arguments = methodCalls[0].arguments as Map<String, dynamic>;
expect(arguments['x'], equals(100.0));
expect(arguments['y'], equals(200.0));
});
test('悬浮窗状态管理', () async {
// 初始状态
expect(floatingWindowManager.isEnabled, isFalse);
expect(floatingWindowManager.currentState, equals(FloatingWindowState.hidden));
// 启用悬浮窗
await floatingWindowManager.enableFloatingWindow();
expect(floatingWindowManager.isEnabled, isTrue);
// 显示悬浮窗
await floatingWindowManager.showFloatingWindow();
// 注意:实际状态变化需要通过原生回调触发,这里只测试方法调用
});
});
group('悬浮窗数据模型测试', () {
test('FloatingWindowData 序列化', () {
final data = FloatingWindowData(
sourceText: '测试文本',
translatedText: 'Test Text',
sourceLanguage: '中文(简体)',
targetLanguage: '英语',
timestamp: DateTime(2024, 1, 1, 12, 0, 0),
isIntermediate: true,
);
final map = data.toMap();
expect(map['sourceText'], equals('测试文本'));
expect(map['translatedText'], equals('Test Text'));
expect(map['sourceLanguage'], equals('中文(简体)'));
expect(map['targetLanguage'], equals('英语'));
expect(map['timestamp'], equals(DateTime(2024, 1, 1, 12, 0, 0).millisecondsSinceEpoch));
expect(map['isIntermediate'], equals(true));
});
});
}
Loading…
Cancel
Save