diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index f8add2d7..1d7a7b95 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -11,7 +11,6 @@ import 'package:floating_ui_plugin/native_plugin.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:pip/pip.dart'; import 'package:eaimar/data/services/qq_music.dart'; import '../../../data/services/ast_service.dart'; import '../../../data/services/server_translation_service.dart'; @@ -99,13 +98,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 安卓悬浮窗管理器 late final FloatingWindowManager _floatingWindowManager; // 苹果悬浮窗管理器 - late final Pip _pip; late final NativePlugin _nativePlugin; - int _pipContentView = 0; - static const int _pipPreferredWidth = 375; - static const int _pipCompactPreferredHeight = 72; - static const int _pipExpandedPreferredHeight = 220; - bool _isPipExpanded = false; // ==================== 悬浮窗相关 ==================== final isFloatingWindowEnabled = false.obs; // 悬浮窗状态 @@ -266,28 +259,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 初始化苹果悬浮窗管理器 Future initPlatformState() async { - _pip = Pip(); _nativePlugin = NativePlugin(); - try { - isFloatingWindowEnabled.value = await _pip.isActived(); - await _pip.registerStateChangedObserver( - PipStateChangedObserver( - onPipStateChanged: (state, error) { - isFloatingWindowEnabled.value = state == PipState.pipStateStarted; - - if (state == PipState.pipStateFailed) { - _isPipExpanded = false; - _pip.dispose(); - unawaited(_releasePipContentView()); - } else if (state == PipState.pipStateStopped) { - _isPipExpanded = false; - } - }, - ), - ); - } on PlatformException { - isFloatingWindowEnabled.value = false; - } + if (!Platform.isIOS) return; + // ⚠️ 不再走 pub 包 `pip`。它给小窗喂的是一整块纯黑像素,真正的字幕卡片 + // 靠把 contentView 搬进「keyWindow」来显示,而 PiP 窗口由系统进程托管、 + // 根本不在 App 的窗口列表里 —— 于是字幕落在 App 自己身上(还盖住整屏), + // 小窗里只有一块黑的。现在改成自绘:把字幕画进 CVPixelBuffer 逐帧喂给 + // AVSampleBufferDisplayLayer,小窗显示的就是它(见原生 SubtitlePipController)。 + isFloatingWindowEnabled.value = await SubtitlePip.isActive(); } /// 切换悬浮窗功能 @@ -296,9 +275,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (Platform.isAndroid) { await _disableFloatingWindow(); } else if (Platform.isIOS) { - _isPipExpanded = false; - await _pip.dispose(); - await _releasePipContentView(); + await SubtitlePip.stop(); + isFloatingWindowEnabled.value = false; } } else { if (Platform.isAndroid) { @@ -309,51 +287,37 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } - PipOptions _buildIosPipOptions({required int preferredHeight}) { - return PipOptions( - autoEnterEnabled: true, - aspectRatioX: _pipPreferredWidth, - aspectRatioY: preferredHeight, - sourceRectHintLeft: 0, - sourceRectHintTop: 0, - sourceRectHintRight: 0, - sourceRectHintBottom: 0, - seamlessResizeEnabled: true, - useExternalStateMonitor: true, - externalStateMonitorInterval: 100, - contentView: _pipContentView, - preferredContentWidth: _pipPreferredWidth, - preferredContentHeight: preferredHeight, - controlStyle: 2, - ); + Future _startPip() async { + final ok = await SubtitlePip.start(); + isFloatingWindowEnabled.value = ok; + if (!ok) { + Logger.w('Translation', '字幕小窗启动失败(系统拒绝或本机不支持画中画)'); + return; + } + // 起来之后把当前样式补一次,否则用户上次调的透明度/宽度这一轮不生效 + await SubtitlePip.setStyle( + opacity: pipOpacity.value, widthScale: pipWidthScale.value); } - /// 释放 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'); - } + /// ⚠️ [pipOpacity] 改的是卡片自己的底色,**不是**「透过小窗看见下面的 App」。 + /// iOS 的 PiP 窗口本身不透明,系统没有开放任何让它半透明的接口。 + /// 真要少挡内容只能把小窗做小(widthScale)或用系统手势把它拖到别处。 + /// + /// ⚠️ [pipWidthScale] 改的是**画布宽高比**。小窗的实际大小由用户捏合手势 + /// 决定,App 无权直接设定;换比例会让系统按新形状重新布局。 + final RxDouble pipOpacity = 0.9.obs; + final RxDouble pipWidthScale = 1.0.obs; + + Future setPipOpacity(double v) async { + pipOpacity.value = v.clamp(0.2, 1.0); + await SubtitlePip.setStyle(opacity: pipOpacity.value); } - Future _startPip() async { - if (_pipContentView == 0) { - _pipContentView = await _nativePlugin.createPipContentView(); - } - _isPipExpanded = false; - await _pip.setup( - _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); - await _pip.start(); + Future setPipWidthScale(double v) async { + pipWidthScale.value = v.clamp(0.5, 2.0); + await SubtitlePip.setStyle(widthScale: pipWidthScale.value); } /// 启用悬浮窗功能 @@ -427,23 +391,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver { ); await _floatingWindowManager.updateFloatingWindowContent(data); } else if (Platform.isIOS) { - final hasContent = item.sourceText.trim().isNotEmpty || - item.translatedText.trim().isNotEmpty; - if (hasContent && !_isPipExpanded) { - final ok = await _pip.setup( - _buildIosPipOptions(preferredHeight: _pipExpandedPreferredHeight)); - if (ok) _isPipExpanded = true; - } else if (!hasContent && _isPipExpanded) { - final ok = await _pip.setup( - _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); - if (ok) _isPipExpanded = false; - } - await _nativePlugin.updatePipContentView({ - 'viewId': _pipContentView, - 'cn': item.sourceText, - 'en': item.translatedText, - 'lang': '$sourceLanguageName -> $targetLanguageName', - }); + // 收起/展开不再需要重新 setup:画布高度跟着卡片内容走, + // 宽高比一变系统就会把小窗重新布形状。 + await SubtitlePip.update( + lang: '$sourceLanguageName -> $targetLanguageName', + cn: item.sourceText, + en: item.translatedText, + ); } } @@ -1123,10 +1077,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (Platform.isAndroid) { _disableFloatingWindow(); } else if (Platform.isIOS) { - _pip.dispose(); - if (_pipContentView != 0) { - _nativePlugin.disposePipContentView(_pipContentView); - } + unawaited(SubtitlePip.stop()); } // 释放管理器资源 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 55623908..1654105b 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 @@ -55,6 +55,53 @@ public class FloatingUiPlugin: NSObject, FlutterPlugin { case "getPlatformVersion": result("iOS " + UIDevice.current.systemVersion) + // ===== 字幕画中画(自绘)===== + // + // 与下面那套 createPipContentView/updatePipContentView 的区别:那一套依赖 + // pub 包 `pip` 把 contentView 搬进 keyWindow,而 PiP 窗口根本不在 App 的 + // 窗口列表里,字幕永远进不去小窗(详见 SubtitlePipController 的注释)。 + // 这一套自己把字幕画进 CVPixelBuffer 喂给 AVSampleBufferDisplayLayer, + // 小窗显示的就是它。 + case "subtitlePipStart": + if #available(iOS 15.0, *) { + Self.updateAudioSession() + result(SubtitlePipController.shared.start()) + } else { + result(FlutterError(code: "UNSUPPORTED", message: "字幕小窗需要 iOS 15 及以上", details: nil)) + } + + case "subtitlePipStop": + if #available(iOS 15.0, *) { + SubtitlePipController.shared.stop() + } + result(nil) + + case "subtitlePipUpdate": + if #available(iOS 15.0, *) { + let data = call.arguments as? [String: Any] + SubtitlePipController.shared.update( + lang: data?["lang"] as? String, + cn: data?["cn"] as? String, + en: data?["en"] as? String) + } + result(nil) + + case "subtitlePipSetStyle": + if #available(iOS 15.0, *) { + let data = call.arguments as? [String: Any] + SubtitlePipController.shared.setStyle( + opacity: (data?["opacity"] as? NSNumber).map { CGFloat($0.doubleValue) }, + widthScale: (data?["widthScale"] as? NSNumber).map { CGFloat($0.doubleValue) }) + } + result(nil) + + case "subtitlePipIsActive": + if #available(iOS 15.0, *) { + result(SubtitlePipController.shared.isActive) + } else { + result(false) + } + case "createPipContentView": Self.updateAudioSession() let playerView = PlayerView() 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 9b0b45b6..905ab062 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 @@ -11,6 +11,7 @@ public class PlayerView: UIView { /// 挂在 cardView 上、并且只把 cardView 钉在顶部,最坏情况也只是顶部一条字幕条。 private let cardView: UIView = UIView() private var cardHeightConstraint: NSLayoutConstraint? + private var cardBottomConstraint: NSLayoutConstraint? private let titleLabel: UILabel = UILabel() private let langLabel: UILabel = UILabel() private let langContainer: UIView = UIView() @@ -156,6 +157,46 @@ public class PlayerView: UIView { ]) } + // MARK: - 供 PiP 离屏渲染使用 + + /// 当前内容需要的画布尺寸(宽度给定,高度由展开/收起状态决定)。 + /// + /// PiP 小窗的宽高比由我们喂进去的 pixel buffer 尺寸决定,所以这个值变了, + /// 系统的小窗也会跟着变形状 —— 「有内容时变高、没内容时收成一条」就是 + /// 靠它实现的。 + public func canvasSize(width: CGFloat) -> CGSize { + CGSize(width: width, height: isExpanded ? expandedPreferredHeight : compactPreferredHeight) + } + + /// 卡片背景的不透明度(0~1)。 + /// + /// ⚠️ 这**不是**「透过弹幕看到下面的 App」。iOS 的 PiP 窗口本身是不透明的, + /// 系统不给任何接口让它变成半透明——alpha 只是让卡片在小窗自己那块黑底上 + /// 显得深一点浅一点。真正减少遮挡只能靠把小窗做小、或者挪到别处。 + public func setBackgroundAlpha(_ alpha: CGFloat) { + let a = max(0, min(1, alpha)) + cardView.backgroundColor = UIColor(red: 40.0 / 255.0, + green: 40.0 / 255.0, + blue: 40.0 / 255.0, + alpha: a) + cardView.layer.borderColor = UIColor(white: 1.0, alpha: 46.0 / 255.0 * a).cgColor + } + + /// 让 cardView 铺满整个 view —— 离屏渲染时画布就是卡片,不需要顶部对齐。 + public func setFillsBounds(_ fills: Bool) { + cardHeightConstraint?.isActive = !fills + if fills { + if cardBottomConstraint == nil { + cardBottomConstraint = cardView.bottomAnchor.constraint(equalTo: bottomAnchor) + } + cardBottomConstraint?.isActive = true + } else { + cardBottomConstraint?.isActive = false + cardHeightConstraint?.isActive = true + } + setNeedsLayout() + } + public func play() { } diff --git a/apps/client/local_plugins/floating_ui_plugin/ios/Classes/SubtitlePipController.swift b/apps/client/local_plugins/floating_ui_plugin/ios/Classes/SubtitlePipController.swift new file mode 100644 index 00000000..d38f195d --- /dev/null +++ b/apps/client/local_plugins/floating_ui_plugin/ios/Classes/SubtitlePipController.swift @@ -0,0 +1,294 @@ +import AVFoundation +import AVKit +import UIKit + +/// 自己实现的字幕画中画。 +/// +/// ## 为什么不用 pub 上的 `pip` 包 +/// 那个包给 PiP 喂的是**一整块纯黑像素**(PipView.m 里 `pixel = 0xFF000000` +/// 填满 pixelBuffer),只是个占位帧;真正的字幕卡片它是靠 +/// `insertContentViewIfNeeded` 塞进「keyWindow」的。而 PiP 窗口由系统进程托管、 +/// **不在 `UIApplication.connectedScenes` 的窗口列表里**,那个 keyWindow 永远 +/// 是 App 自己的窗口。结果就是:字幕落在 App 自己身上(还盖住整个屏幕), +/// PiP 小窗里只有一块黑的。用户看到的「弹幕上没有翻译内容」就是这么来的。 +/// +/// 正确做法只有一条:**自己把字幕画进 CVPixelBuffer,逐帧喂给 +/// AVSampleBufferDisplayLayer**。小窗显示的就是这个 layer 的内容。 +/// +/// ## ⚠️ 关于透明度 +/// iOS 的 PiP 窗口本身不透明,系统没有任何接口能让它变成半透明。 +/// [setStyle] 里的 opacity 只作用于卡片自己的底色(在小窗那块黑底之上), +/// **做不到透过小窗看见下面的 App**。要少挡内容只能把小窗做小, +/// 或者用系统手势把它拖到别处 / 收到屏幕边缘。 +/// +/// ## ⚠️ 关于大小 +/// PiP 小窗的尺寸由用户捏合手势控制,App 无权直接设定;我们能决定的是 +/// **宽高比**(= 喂进去的 pixel buffer 的宽高比)。所以"放大缩小"在这里表现为 +/// 换一个画布比例,系统会按新比例重新布局小窗。 +@available(iOS 15.0, *) +final class SubtitlePipController: NSObject { + static let shared = SubtitlePipController() + + /// 承载 AVSampleBufferDisplayLayer 的宿主 view + private final class RenderView: UIView { + override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } + var displayLayer: AVSampleBufferDisplayLayer { + // swiftlint:disable:next force_cast + layer as! AVSampleBufferDisplayLayer + } + } + + private let renderView = RenderView() + private let card = PlayerView() + private var pip: AVPictureInPictureController? + private var timebase: CMTimebase? + private var heartbeat: Timer? + + /// 画布宽度。高度由卡片当前是收起还是展开决定,见 PlayerView.canvasSize。 + private var canvasWidth: CGFloat = 375 + private var lastCanvasSize: CGSize = .zero + + private(set) var isActive = false + + private override init() { + super.init() + card.setFillsBounds(true) + } + + // MARK: - 生命周期 + + @discardableResult + func start() -> Bool { + guard AVPictureInPictureController.isPictureInPictureSupported() else { + NSLog("[SubtitlePip] 本机不支持画中画") + return false + } + attachRenderViewIfNeeded() + setupTimebaseIfNeeded() + // 先推一帧再 start:AVPictureInPictureController 在没有任何已入队帧时 + // 可能拒绝启动(内容源被判定为"没有内容"),表现是回调里直接 failed。 + renderAndEnqueue(force: true) + + if pip == nil { + let source = AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: renderView.displayLayer, + playbackDelegate: self + ) + let controller = AVPictureInPictureController(contentSource: source) + controller.delegate = self + // 字幕不是可拖动进度的媒体,去掉播放/快进那套控件 + controller.requiresLinearPlayback = true + // 用户切走 App 时自动进小窗——这正是本功能的使用场景 + controller.canStartPictureInPictureAutomaticallyFromInline = true + pip = controller + } + pip?.startPictureInPicture() + + // 心跳:定期补一帧。 + // ⚠️ 不能只在文案变化时推帧 —— 长时间没有新帧时 + // AVSampleBufferDisplayLayer 可能被系统判定为停滞并清空, + // 表现是「翻译停一会儿,小窗就黑了」。 + heartbeat?.invalidate() + heartbeat = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.renderAndEnqueue(force: false) + } + return true + } + + func stop() { + heartbeat?.invalidate() + heartbeat = nil + pip?.stopPictureInPicture() + renderView.removeFromSuperview() + isActive = false + } + + // MARK: - 内容与样式 + + func update(lang: String?, cn: String?, en: String?) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.card.update(withLang: lang, cn: cn, en: en) + self.renderAndEnqueue(force: true) + } + } + + /// [opacity] 卡片底色不透明度 0~1;[widthScale] 画布宽度相对基准 375 的倍数, + /// 通过改变宽高比让系统把小窗重新布成更宽/更窄的形状。 + func setStyle(opacity: CGFloat?, widthScale: CGFloat?) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let opacity = opacity { self.card.setBackgroundAlpha(opacity) } + if let scale = widthScale { + self.canvasWidth = max(180, min(750, 375 * scale)) + } + self.renderAndEnqueue(force: true) + } + } + + // MARK: - 渲染 + + private func attachRenderViewIfNeeded() { + guard renderView.superview == nil else { return } + // ⚠️ 必须挂在真实的窗口层级里,且尺寸非零:脱离层级的 layer 不会被 + // PiP 采纳,表现是 start 之后立刻回调 failed。做成几乎全透明即可, + // 它在 App 内不需要被看见。 + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first else { + return + } + renderView.alpha = 0.01 + renderView.isUserInteractionEnabled = false + renderView.frame = CGRect(x: 0, y: 0, width: 1, height: 1) + window.addSubview(renderView) + } + + private func setupTimebaseIfNeeded() { + guard timebase == nil else { return } + var tb: CMTimebase? + CMTimebaseCreateWithSourceClock(allocator: kCFAllocatorDefault, + sourceClock: CMClockGetHostTimeClock(), + timebaseOut: &tb) + if let tb = tb { + CMTimebaseSetTime(tb, time: .zero) + CMTimebaseSetRate(tb, rate: 1.0) + renderView.displayLayer.controlTimebase = tb + timebase = tb + } + } + + private func renderAndEnqueue(force: Bool) { + let size = card.canvasSize(width: canvasWidth) + if size != lastCanvasSize { + lastCanvasSize = size + card.frame = CGRect(origin: .zero, size: size) + } + card.setNeedsLayout() + card.layoutIfNeeded() + + guard let pixelBuffer = makePixelBuffer(size: size), + let sample = makeSampleBuffer(pixelBuffer) else { return } + let layer = renderView.displayLayer + if layer.status == .failed { layer.flush() } + layer.enqueue(sample) + } + + private func makePixelBuffer(size: CGSize) -> CVPixelBuffer? { + let w = max(2, Int(size.width)) + let h = max(2, Int(size.height)) + var pb: CVPixelBuffer? + let attrs: [String: Any] = [ + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any]() + ] + guard CVPixelBufferCreate(kCFAllocatorDefault, w, h, + kCVPixelFormatType_32BGRA, + attrs as CFDictionary, &pb) == kCVReturnSuccess, + let pixelBuffer = pb else { return nil } + + CVPixelBufferLockBaseAddress(pixelBuffer, []) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil } + + guard let ctx = CGContext( + data: base, + width: w, height: h, + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) else { return nil } + + ctx.clear(CGRect(x: 0, y: 0, width: w, height: h)) + // ⚠️ UIKit 原点在左上、CoreGraphics 在左下。不翻这一下,字幕是上下颠倒的。 + ctx.translateBy(x: 0, y: CGFloat(h)) + ctx.scaleBy(x: 1, y: -1) + UIGraphicsPushContext(ctx) + card.layer.render(in: ctx) + UIGraphicsPopContext() + return pixelBuffer + } + + private func makeSampleBuffer(_ pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? { + var formatDesc: CMVideoFormatDescription? + guard CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &formatDesc) == noErr, + let desc = formatDesc else { return nil } + + let now = timebase.map { CMTimebaseGetTime($0) } ?? .zero + var timing = CMSampleTimingInfo( + duration: CMTimeMakeWithSeconds(1, preferredTimescale: 600), + presentationTimeStamp: now, + decodeTimeStamp: .invalid + ) + var sample: CMSampleBuffer? + guard CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: desc, + sampleTiming: &timing, + sampleBufferOut: &sample) == noErr else { return nil } + return sample + } +} + +// MARK: - AVPictureInPictureControllerDelegate + +@available(iOS 15.0, *) +extension SubtitlePipController: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerDidStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController) { + isActive = true + NSLog("[SubtitlePip] 已进入画中画") + renderAndEnqueue(force: true) + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + failedToStartPictureInPictureWithError error: Error) { + isActive = false + NSLog("[SubtitlePip] 进入画中画失败: %@", error.localizedDescription) + } + + func pictureInPictureControllerDidStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController) { + isActive = false + NSLog("[SubtitlePip] 已退出画中画") + } +} + +// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate + +@available(iOS 15.0, *) +extension SubtitlePipController: AVPictureInPictureSampleBufferPlaybackDelegate { + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, + setPlaying playing: Bool) {} + + /// 返回无限区间 = 告诉系统这是直播流,小窗上就不会出现进度条和快进按钮。 + func pictureInPictureControllerTimeRangeForPlayback( + _ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + CMTimeRange(start: .negativeInfinity, duration: .positiveInfinity) + } + + func pictureInPictureControllerIsPlaybackPaused( + _ pictureInPictureController: AVPictureInPictureController) -> Bool { + false + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, + didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + // 用户捏合改了小窗大小。这里不改画布:画布比例是我们定的, + // 系统会自己把内容缩放到新尺寸。 + NSLog("[SubtitlePip] 小窗尺寸变为 %dx%d", newRenderSize.width, newRenderSize.height) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, + skipByInterval skipInterval: CMTime, + completion completionHandler: @escaping () -> Void) { + completionHandler() + } +} diff --git a/apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart b/apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart index 3650779a..50c04d2e 100644 --- a/apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart +++ b/apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart @@ -78,3 +78,64 @@ class TranslationFloatingWindowApi { return channel.invokeMethod('setPosition', {'x': x, 'y': y}); } } + +/// 字幕画中画(iOS)。自绘内容喂给 AVSampleBufferDisplayLayer, +/// 与 [NativePlugin.createPipContentView] 那套**不是**一回事: +/// 那一套依赖 pub 包 `pip` 把 contentView 搬进 keyWindow,而 PiP 窗口不在 +/// App 的窗口列表里,字幕永远进不了小窗(原生侧 SubtitlePipController 有详述)。 +/// +/// Android 上全部是空操作 —— 那边用的是真正的系统悬浮窗。 +class SubtitlePip { + SubtitlePip._(); + + static const MethodChannel _channel = MethodChannel('native_plugin'); + + /// 进入画中画。返回 false 表示本机不支持或启动被系统拒绝。 + static Future start() async { + try { + return (await _channel.invokeMethod('subtitlePipStart')) ?? false; + } catch (_) { + return false; + } + } + + static Future stop() async { + try { + await _channel.invokeMethod('subtitlePipStop'); + } catch (_) {} + } + + /// 更新小窗里显示的内容。[lang] 是「源语言 → 目标语言」那一行。 + static Future update({String? lang, String? cn, String? en}) async { + try { + await _channel.invokeMethod('subtitlePipUpdate', { + 'lang': lang, + 'cn': cn, + 'en': en, + }); + } catch (_) {} + } + + /// [opacity] 卡片底色不透明度 0~1。 + /// [widthScale] 画布宽度倍数(基准 375),改的是**宽高比**—— + /// 小窗实际大小由用户捏合决定,App 无权直接设定。 + /// + /// ⚠️ opacity 做不到「透过小窗看见下面的 App」:iOS 的 PiP 窗口本身不透明, + /// 系统没有开放任何接口。它只让卡片在小窗自己那块底上显得深浅不同。 + static Future setStyle({double? opacity, double? widthScale}) async { + try { + await _channel.invokeMethod('subtitlePipSetStyle', { + if (opacity != null) 'opacity': opacity, + if (widthScale != null) 'widthScale': widthScale, + }); + } catch (_) {} + } + + static Future isActive() async { + try { + return (await _channel.invokeMethod('subtitlePipIsActive')) ?? false; + } catch (_) { + return false; + } + } +}