diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 5f26eddc..9cb77ba6 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/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 _releasePipContentView() async { + if (_pipContentView == 0) return; + final int viewId = _pipContentView; + _pipContentView = 0; + try { + await _nativePlugin.disposePipContentView(viewId); + } catch (e) { + Logger.warning('释放 PiP 字幕视图失败(不影响退出弹幕): $e'); + } + } + Future _startPip() async { if (_pipContentView == 0) { _pipContentView = await _nativePlugin.createPipContentView(); diff --git a/apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift b/apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift index ac1d7152..55623908 100644 --- a/apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift +++ b/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 } diff --git a/apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift b/apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift index a0d69019..9b0b45b6 100644 --- a/apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift +++ b/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