feat(player): 接入双端画中画播放

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-28 16:36:55 +08:00
1 parent 5346be40a9
commit 43e60e29ec
10 files changed
+1015 -29

No files matched your search

+399
View File
@@ -1,6 +1,7 @@
import Flutter
import UIKit
import AVFoundation
import AVKit
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
@@ -35,6 +36,318 @@ final class RunnerFlutterViewController: FlutterViewController {
}
}
/// iOS 影子播放器:独立 AVPlayer + AVPlayerLayer 专供画中画窗口。
/// Flutter 的 video_player 走纹理渲染,没有 AVPlayerLayer,无法直接创建
/// AVPictureInPictureController;小窗的画面与声音由影子接管,退出时把
/// 影子进度回传给 Dart,由 Flutter 播放器 seek 对齐后继续。
final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate {
static let shared = PipShadowCoordinator()
private var player: AVPlayer?
private var playerLayer: AVPlayerLayer?
private var controller: AVPictureInPictureController?
private var endObserver: NSObjectProtocol?
private var possibleObservation: NSKeyValueObservation?
/// 画中画还原过渡层:原生窗口级红晕渐变,在系统还原动画开始时立即盖上,
/// 避免回前台瞬间 Flutter 表面未恢复显示黑帧;视频出帧后由 Dart 通知淡出。
private var restoreOverlay: UIView?
/// 系统要求 playerLayer 位于真实的 view 层级中才会判定画中画可用,
/// 因此用 1x1 透明宿主 view 承载,而不是直接挂在 window.layer 上。
private var hostView: UIView?
/// 影子图层就绪稍晚于创建,置位后由 KVO 自动拉起画中画。
private var pendingStart = false
private var startRetryWorkItems: [DispatchWorkItem] = []
weak var channel: FlutterMethodChannel?
/// active 变化回调;停止时把影子最终进度(毫秒)一并提供。
var onPipChanged: ((Bool, Int64) -> Void)?
/// 影子在画中画里播完当前集。
var onPlaybackEnded: (() -> Void)?
static var isSupported: Bool {
AVPictureInPictureController.isPictureInPictureSupported()
}
private var keyWindow: UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
/// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。
/// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。
func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) {
teardown()
log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)")
guard Self.isSupported else { return }
guard let url = URL(string: urlString) else {
log("prepare failed bad url \(urlString)")
return
}
guard let window = keyWindow else {
log("prepare failed no key window")
return
}
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
p.isMuted = true
p.actionAtItemEnd = .pause
let layer = AVPlayerLayer()
layer.player = p
let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
host.isUserInteractionEnabled = false
host.backgroundColor = .clear
window.addSubview(host)
layer.frame = host.bounds
host.layer.addSublayer(layer)
hostView = host
guard let c = AVPictureInPictureController(playerLayer: layer) else {
p.pause()
log("prepare failed no pip controller")
return
}
c.canStartPictureInPictureAutomaticallyFromInline = autoEnter
c.delegate = self
player = p
playerLayer = layer
controller = c
// 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。
p.play()
possibleObservation = c.observe(
\.isPictureInPicturePossible,
options: [.new, .initial]
) { [weak self] controller, change in
let value = change.newValue ?? controller.isPictureInPicturePossible
self?.log("possible changed to \(value)")
guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return }
self.pendingStart = false
// 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。
DispatchQueue.main.async {
guard self.controller === controller, controller.isPictureInPicturePossible else {
self.log("start via kvo aborted (shadow replaced)")
return
}
self.log("start via kvo")
controller.startPictureInPicture()
}
}
log("shadow ready, seeking to \(positionMs)ms")
if positionMs > 0 {
p.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
registerEndObserver(for: item)
activateAudioSession()
}
/// 退出 App 前(inactive)把影子对齐到当前进度并静音起播:
/// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。
func armAutoEnter(positionMs: Int64) {
guard let player, let controller else {
log("arm failed, shadow missing")
return
}
guard controller.canStartPictureInPictureAutomaticallyFromInline else {
log("arm failed, autoEnter disabled on shadow")
return
}
log("arm auto enter at \(positionMs)ms")
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: CMTime(value: 800, timescale: 1000),
toleranceAfter: CMTime(value: 800, timescale: 1000)
)
}
player.play()
}
/// 回前台后取消待命:影子暂停回静音,等待下一次退后台。
func disarmAutoEnter() {
guard let player else { return }
log("disarm auto enter")
player.pause()
player.isMuted = true
}
/// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。
/// 仅当影子从未创建成功(prepare 失败)时返回 false。
func start() -> Bool {
guard let controller else {
log("start failed, shadow not created")
return false
}
log("start requested possible=\(controller.isPictureInPicturePossible)")
pendingStart = true
if controller.isPictureInPicturePossible {
pendingStart = false
controller.startPictureInPicture()
}
scheduleStartRetries()
return true
}
/// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。
private func scheduleStartRetries() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
for delay in [1.0, 2.5] {
let item = DispatchWorkItem { [weak self] in
guard
let self,
self.pendingStart,
let controller = self.controller,
controller.isPictureInPicturePossible
else { return }
self.log("start via retry after \(delay)s")
self.pendingStart = false
controller.startPictureInPicture()
}
startRetryWorkItems.append(item)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
}
}
/// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。
func replaceItem(urlString: String, positionMs: Int64) {
guard let player, let url = URL(string: urlString) else { return }
let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)
registerEndObserver(for: item)
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
}
/// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。
func unmuteAndPlay() {
player?.isMuted = false
player?.play()
}
func positionMs() -> Int64 {
Int64(max(0, player?.currentTime().seconds ?? 0) * 1000)
}
func stopPip() {
controller?.stopPictureInPicture()
}
func teardown() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
possibleObservation?.invalidate()
possibleObservation = nil
pendingStart = false
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = nil
player?.pause()
player?.replaceCurrentItem(with: nil)
playerLayer?.removeFromSuperlayer()
playerLayer = nil
hostView?.removeFromSuperview()
hostView = nil
controller?.delegate = nil
controller = nil
player = nil
}
/// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。
private func log(_ message: String) {
NSLog("pip: %@", message)
channel?.invokeMethod("pipDebug", arguments: message)
}
private func registerEndObserver(for item: AVPlayerItem) {
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.log("item did play to end")
self?.onPlaybackEnded?()
}
}
/// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。
private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
// 会话激活失败只影响后台出声,不阻断画中画。
}
}
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will start")
pendingStart = false
onPipChanged?(true, 0)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will stop, showing restore overlay")
showRestoreOverlay()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("did stop")
showRestoreOverlay()
onPipChanged?(false, positionMs())
}
/// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。
func showRestoreOverlay() {
guard let window = keyWindow else { return }
restoreOverlay?.removeFromSuperview()
let overlay = UIView(frame: window.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let gradient = CAGradientLayer()
gradient.frame = overlay.bounds
gradient.colors = [
UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor,
UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor,
UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor,
]
gradient.locations = [0, 0.22, 0.62]
gradient.startPoint = CGPoint(x: 0.5, y: 0)
gradient.endPoint = CGPoint(x: 0.5, y: 1)
overlay.layer.addSublayer(gradient)
window.addSubview(overlay)
restoreOverlay = overlay
}
/// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。
func hideRestoreOverlay() {
guard let overlay = restoreOverlay else { return }
restoreOverlay = nil
UIView.animate(
withDuration: 0.25,
animations: { overlay.alpha = 0 }
) { _ in overlay.removeFromSuperview() }
}
}
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let wirelessDataPrompt = WirelessDataPrompt()
@@ -64,6 +377,7 @@ final class RunnerFlutterViewController: FlutterViewController {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerWirelessDataChannel(engineBridge)
registerDeviceIdChannel(engineBridge)
registerPipChannel(engineBridge)
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/share",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
@@ -145,6 +459,91 @@ final class RunnerFlutterViewController: FlutterViewController {
}
}
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/pip",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
let pip = PipShadowCoordinator.shared
pip.channel = channel
channel.setMethodCallHandler { call, result in
switch call.method {
case "isAvailable":
result(PipShadowCoordinator.isSupported)
case "enter":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(false)
return
}
let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0
pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false)
// 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。
DispatchQueue.main.async {
result(pip.start())
}
case "prepareShadow":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.prepare(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0,
autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false
)
result(nil)
case "armAutoEnter":
pip.armAutoEnter(
positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0
)
result(nil)
case "showRestoreOverlay":
pip.showRestoreOverlay()
result(nil)
case "hideRestoreOverlay":
pip.hideRestoreOverlay()
result(nil)
case "disarmAutoEnter":
pip.disarmAutoEnter()
result(nil)
case "unmute":
pip.unmuteAndPlay()
result(nil)
case "replaceItem":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.replaceItem(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0
)
result(nil)
case "stop":
pip.stopPip()
result(nil)
case "teardown":
pip.teardown()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
pip.onPipChanged = { active, positionMs in
channel.invokeMethod(
"onPipChanged",
arguments: ["active": active, "positionMs": positionMs]
)
}
pip.onPlaybackEnded = {
channel.invokeMethod("onPipPlaybackEnded", arguments: nil)
}
}
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
+4
View File
@@ -87,6 +87,10 @@
</array>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>