Browse Source

修 iOS 多媒体翻译开弹幕后整屏点不动

三段代码叠出来的,安卓不受影响(走的是系统悬浮窗,另一条路):

1. pip 包(pub: pip 0.0.4)在 PiP 启动回调里,把 contentView insertSubview 进
   **App 自己的 keyWindow** 并 `frame = window.frame` 铺满全屏。它的
   activeKeyWindow 只按「前台活跃场景的 keyWindow」找父视图,而我们是在前台
   主动 _pip.start() 的,于是必然落到 App 自己的窗口上。
2. PlayerView 是普通 UIView,isUserInteractionEnabled 默认 YES,背景还是
   rgba(40,40,40,0.35)。铺满之后=一层吃掉所有触摸的半透明黑罩子。
3. 关掉弹幕也恢复不了:插件的 restoreContentViewIfNeeded 在
   originalParentView == nil 时直接 return,而 PlayerView 是 createPipContentView
   里凭空 new 出来的、从来没有过 superview,那条还原分支永远走不到。
   罩子会一直留到进程被杀。

改动(都在我们自己这边,没有 fork pub 包):
- PlayerView.isUserInteractionEnabled = false。字幕卡片是纯展示,任何情况下
  都不该吃触摸——这一条单独就能解掉「点不动」。
- 视觉(底色/圆角/描边/阴影)从 self 搬到内层 cardView,self 透明;
  cardView 只钉 top/leading/trailing + 固定高度,不钉 bottom。真 PiP 小窗里
  窗口高度就等于这个高度、看着是铺满的;万一又被铺到全屏窗口上,也只占顶部
  一条,下面保持透明。
- disposePipContentView 里补 removeFromSuperview(插件不会替我们摘)。
- Dart 关闭弹幕/PiP 失败时调 _releasePipContentView(),并把 _pipContentView
  清零,避免复用一个已经被搬走、再也摘不下来的 view。

⚠️ 未验证:PiP 小窗本身能不能正确显示字幕。pip 包的 PipView 只往
sampleBufferDisplayLayer 里塞自己造的 buffer,全文件没有一处引用 contentView,
所以「切出 App 后小窗里有没有字」这件事需要真机确认,本次没测。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
8841e76e59
  1. 20
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  2. 8
      apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift
  3. 72
      apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift

20
apps/client/lib/modules/translation/controllers/translation_controller.dart

@ -278,6 +278,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (state == PipState.pipStateFailed) {
_isPipExpanded = false;
_pip.dispose();
unawaited(_releasePipContentView());
} else if (state == PipState.pipStateStopped) {
_isPipExpanded = false;
}
@ -297,6 +298,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} else if (Platform.isIOS) {
_isPipExpanded = false;
await _pip.dispose();
await _releasePipContentView();
}
} else {
if (Platform.isAndroid) {
@ -326,6 +328,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
);
}
/// 释放 PiP 的字幕视图,并且**必须把 id 清零**,下次开启时重新 new 一个。
///
/// ⚠️ 不清零的话会复用一个已经被 pip 插件搬进 App keyWindow、
/// 而且再也摘不下来的 view:插件的 restoreContentViewIfNeeded 在
/// `originalParentView == nil` 时直接 return,而这个 view 是我们凭空
/// new 出来的、从来没有过 superview,所以那个还原分支永远走不到。
/// 结果就是关掉弹幕之后它仍然铺在屏幕上(现在至少不吃触摸了,但仍然多余)。
Future<void> _releasePipContentView() async {
if (_pipContentView == 0) return;
final int viewId = _pipContentView;
_pipContentView = 0;
try {
await _nativePlugin.disposePipContentView(viewId);
} catch (e) {
Logger.warning('释放 PiP 字幕视图失败(不影响退出弹幕): $e');
}
}
Future<void> _startPip() async {
if (_pipContentView == 0) {
_pipContentView = await _nativePlugin.createPipContentView();

8
apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift

@ -75,6 +75,14 @@ public class FloatingUiPlugin: NSObject, FlutterPlugin {
let currentId = UInt64(UInt(bitPattern: Unmanaged.passUnretained(playerView).toOpaque()))
if currentId == targetId {
playerView.pause()
// ⚠️ 必须自己摘:pip 插件的 restoreContentViewIfNeeded 在
// `originalParentView == nil` 时直接 return,而这个 view 是这里
// 凭空 new 出来的、从来没有过 superview,所以那条还原路径永远不会
// 把它从 App 的 keyWindow 上拿下来。不摘的话它会一直铺在屏幕上,
// 直到进程被杀。
DispatchQueue.main.async {
playerView.removeFromSuperview()
}
pipContentViewArray.remove(at: idx)
break
}

72
apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift

@ -1,6 +1,16 @@
import UIKit
public class PlayerView: UIView {
/// 真正的卡片。视觉(底色/圆角/描边/阴影)全挂在它身上,**不挂在 self 上**。
///
/// ⚠️ 这是为了兜住 pip 插件的一个行为:PiP 启动时它会把本 view
/// `insertSubview` 进 **App 自己的 keyWindow** 并 `frame = window.frame`
/// 铺满全屏(PipController.m 的 pictureInPictureControllerDidStartPictureInPicture
/// 只按「前台活跃场景的 keyWindow」找父视图,而我们是在前台主动 start 的)。
/// 底色若挂在 self 上,铺满之后就是一整块半透明黑罩子盖住整个 App。
/// 挂在 cardView 上、并且只把 cardView 钉在顶部,最坏情况也只是顶部一条字幕条。
private let cardView: UIView = UIView()
private var cardHeightConstraint: NSLayoutConstraint?
private let titleLabel: UILabel = UILabel()
private let langLabel: UILabel = UILabel()
private let langContainer: UIView = UIView()
@ -32,14 +42,23 @@ public class PlayerView: UIView {
}
private func setupUI() {
backgroundColor = UIColor(red: 40.0 / 255.0, green: 40.0 / 255.0, blue: 40.0 / 255.0, alpha: 90.0 / 255.0)
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = UIColor(white: 1.0, alpha: 46.0 / 255.0).cgColor
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.35
layer.shadowRadius = 12
layer.shadowOffset = CGSize(width: 0, height: 4)
// ⚠️ 字幕卡片是纯展示,**永远不该吃触摸**。
// 不加这行的话,一旦它被 pip 插件铺满 App 窗口(见 cardView 的注释),
// 整个屏幕就点不动了,而且关掉弹幕也恢复不了——插件的 restoreContentViewIfNeeded
// 在 originalParentView 为 nil 时直接 return,不会把它摘下来,
// 而我们这个 view 是凭空 new 出来的、本来就没有 superview。
isUserInteractionEnabled = false
backgroundColor = .clear
cardView.backgroundColor = UIColor(red: 40.0 / 255.0, green: 40.0 / 255.0, blue: 40.0 / 255.0, alpha: 90.0 / 255.0)
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(white: 1.0, alpha: 46.0 / 255.0).cgColor
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.35
cardView.layer.shadowRadius = 12
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
cardView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.text = "实时翻译"
titleLabel.font = .boldSystemFont(ofSize: 22)
@ -115,11 +134,21 @@ public class PlayerView: UIView {
stackView.addArrangedSubview(cnLabel)
stackView.addArrangedSubview(enLabel)
layoutMargins = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)
addSubview(stackView)
cardView.layoutMargins = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)
addSubview(cardView)
cardView.addSubview(stackView)
let guide = layoutMarginsGuide
// cardView 只钉 top/leading/trailing + 固定高度,**不钉 bottom**:
// 在真正的 PiP 小窗里窗口高度就等于这个高度,看起来是铺满的;
// 万一被铺到 App 全屏窗口上,也只占顶部一条,下面依然是透明的。
let height = cardView.heightAnchor.constraint(equalToConstant: compactPreferredHeight)
cardHeightConstraint = height
let guide = cardView.layoutMarginsGuide
NSLayoutConstraint.activate([
cardView.topAnchor.constraint(equalTo: topAnchor),
cardView.leadingAnchor.constraint(equalTo: leadingAnchor),
cardView.trailingAnchor.constraint(equalTo: trailingAnchor),
height,
stackView.topAnchor.constraint(equalTo: guide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
@ -161,11 +190,12 @@ public class PlayerView: UIView {
public override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = isExpanded ? 16 : 18
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath
cardView.layer.cornerRadius = isExpanded ? 16 : 18
cardView.layer.shadowPath =
UIBezierPath(roundedRect: cardView.bounds, cornerRadius: cardView.layer.cornerRadius).cgPath
let currentLangText = langLabel.text
if bounds.size != lastBoundsSize || currentLangText != lastLangText {
lastBoundsSize = bounds.size
if cardView.bounds.size != lastBoundsSize || currentLangText != lastLangText {
lastBoundsSize = cardView.bounds.size
lastLangText = currentLangText
updateDynamicTextLineLimit()
}
@ -176,14 +206,16 @@ public class PlayerView: UIView {
isExpanded = expanded
cnLabel.isHidden = !expanded
enLabel.isHidden = !expanded
layoutMargins = expanded
cardView.layoutMargins = expanded
? UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
: UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16)
cardHeightConstraint?.constant =
expanded ? expandedPreferredHeight : compactPreferredHeight
invalidateIntrinsicContentSize()
}
private func updateDynamicTextLineLimit() {
let fittingWidth = max(0, bounds.width - layoutMargins.left - layoutMargins.right)
let fittingWidth = max(0, cardView.bounds.width - cardView.layoutMargins.left - cardView.layoutMargins.right)
if fittingWidth <= 0 { return }
let visibleArrangedSubviews = stackView.arrangedSubviews.filter { !$0.isHidden }
@ -199,9 +231,9 @@ public class PlayerView: UIView {
let totalSpacing = CGFloat(max(0, visibleArrangedSubviews.count - 1)) * stackView.spacing
let availableHeight =
bounds.height
- layoutMargins.top
- layoutMargins.bottom
cardView.bounds.height
- cardView.layoutMargins.top
- cardView.layoutMargins.bottom
- fixedHeight
- totalSpacing

Loading…
Cancel
Save