Browse Source

字幕小窗改成自绘:pip 包只喂了一块纯黑像素,字幕根本进不去小窗

用户反馈「悬浮弹幕上没有显示翻译内容」,根因在 pub 包 pip 0.0.4:

  PipView.m: const int pixel = 0xFF000000;   // BGRA 全黑
             填满 pixelBuffer → enqueue 进 AVSampleBufferDisplayLayer

小窗显示的就是这一帧纯黑占位。真正的字幕卡片它是靠
insertContentViewIfNeeded 塞进 activeKeyWindow 的,而 PiP 窗口由系统进程托管、
**不在 UIApplication.connectedScenes 的窗口列表里**,那个 keyWindow 永远是
App 自己的窗口。于是字幕落在 App 身上(上一版还因此盖住整个屏幕点不动),
小窗里只可能是黑的。PipView.m 全文件 0 处引用 contentView,可以佐证。

改成自己实现:SubtitlePipController 把 PlayerView 画进 CVPixelBuffer,
逐帧喂给自己的 AVSampleBufferDisplayLayer,再用 sampleBufferDisplayLayer
这一路 content source 起 AVPictureInPictureController。小窗显示的就是它。
Dart 侧不再依赖 pip 包(import 与相关字段一并删除)。

同时回答另外两个问题:
- 大小:PiP 小窗的实际尺寸由用户捏合手势决定,App 无权直接设定。能控的是
  **宽高比**(= 画布宽高比),setPipWidthScale 改的是这个,系统会按新形状
  重新布局。另外卡片有内容时自动变高、没内容时收成一条,也走同一条路。
- 透明度:**做不到透过小窗看见下面的 App**。iOS 的 PiP 窗口本身不透明,
  系统没有开放任何接口。setPipOpacity 只作用于卡片自己的底色(在小窗那块底
  之上显得深浅不同)。真要少挡内容只能把小窗做小或拖到别处。
  这一条在 Dart 与原生两侧的注释里都写明了,免得以后有人再试一遍。

几处实现要点:
- 心跳定时器每秒补一帧。只在文案变化时推帧的话,长时间没有新帧会让
  AVSampleBufferDisplayLayer 被判定停滞并清空,表现是「翻译停一会儿小窗就黑」。
- start() 之前先推一帧:没有任何已入队帧时 AVPictureInPictureController
  会判定内容源为空,直接回调 failed。
- 渲染上下文要做一次 y 轴翻转,否则字幕上下颠倒(UIKit 原点左上、CG 左下)。
- renderView 必须挂在真实窗口层级里且尺寸非零,脱离层级的 layer 不被 PiP 采纳。
- 时间轴返回无限区间,让系统按直播流处理,小窗上不出现进度条和快进按钮。

⚠️ 构建注意:给本地 pod 加新文件后必须重跑 pod install,否则文件不进 Pods
工程,报 "Cannot find 'SubtitlePipController' in scope"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
56aa55773d
  1. 135
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  2. 47
      apps/client/local_plugins/floating_ui_plugin/ios/Classes/NativePlugin.swift
  3. 41
      apps/client/local_plugins/floating_ui_plugin/ios/Classes/PlayerView.swift
  4. 294
      apps/client/local_plugins/floating_ui_plugin/ios/Classes/SubtitlePipController.swift
  5. 61
      apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart

135
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<void> 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<void> _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<void> _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<void> setPipOpacity(double v) async {
pipOpacity.value = v.clamp(0.2, 1.0);
await SubtitlePip.setStyle(opacity: pipOpacity.value);
}
Future<void> _startPip() async {
if (_pipContentView == 0) {
_pipContentView = await _nativePlugin.createPipContentView();
}
_isPipExpanded = false;
await _pip.setup(
_buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight));
await _pip.start();
Future<void> 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());
}
// 释放管理器资源

47
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()

41
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() {
}

294
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()
}
}

61
apps/client/local_plugins/floating_ui_plugin/lib/native_plugin.dart

@ -78,3 +78,64 @@ class TranslationFloatingWindowApi {
return channel.invokeMethod<void>('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<bool> start() async {
try {
return (await _channel.invokeMethod<bool>('subtitlePipStart')) ?? false;
} catch (_) {
return false;
}
}
static Future<void> stop() async {
try {
await _channel.invokeMethod<void>('subtitlePipStop');
} catch (_) {}
}
/// 更新小窗里显示的内容。[lang] 是「源语言 → 目标语言」那一行。
static Future<void> update({String? lang, String? cn, String? en}) async {
try {
await _channel.invokeMethod<void>('subtitlePipUpdate', {
'lang': lang,
'cn': cn,
'en': en,
});
} catch (_) {}
}
/// [opacity] 卡片底色不透明度 0~1。
/// [widthScale] 画布宽度倍数(基准 375),改的是**宽高比**——
/// 小窗实际大小由用户捏合决定,App 无权直接设定。
///
/// ⚠️ opacity 做不到「透过小窗看见下面的 App」:iOS 的 PiP 窗口本身不透明,
/// 系统没有开放任何接口。它只让卡片在小窗自己那块底上显得深浅不同。
static Future<void> setStyle({double? opacity, double? widthScale}) async {
try {
await _channel.invokeMethod<void>('subtitlePipSetStyle', {
if (opacity != null) 'opacity': opacity,
if (widthScale != null) 'widthScale': widthScale,
});
} catch (_) {}
}
static Future<bool> isActive() async {
try {
return (await _channel.invokeMethod<bool>('subtitlePipIsActive')) ?? false;
} catch (_) {
return false;
}
}
}

Loading…
Cancel
Save