feat(apple): use AVFoundation mpv output

This commit is contained in:
edde746
2026-05-08 02:39:42 +02:00
parent 2208d6c637
commit cedd448ef2
24 changed files with 216 additions and 328 deletions
+1 -1
View File
@@ -781,7 +781,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 29803ccbfa631997d496f34c2c1ed191cc8fd2c3;
revision = 8aa5e1abb68fffe1d29511da9e02feca575c9311;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3"
"revision" : "8aa5e1abb68fffe1d29511da9e02feca575c9311"
}
}
],
+16 -104
View File
@@ -1,3 +1,4 @@
import AVFoundation
import AVKit
import UIKit
@@ -22,11 +23,7 @@ import UIKit
weak var delegate: MpvPipDelegate?
var isPipActive: Bool { false }
var autoStartEnabled: Bool { false }
var layerPointer: UnsafeMutableRawPointer {
// Return a dummy non-null pointer layerPointer is handed to mpv for
// rendering into PiP, which never activates on tvOS.
UnsafeMutableRawPointer(bitPattern: 0x1)!
}
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
func setup(with layer: CALayer, containerView: UIView) {}
func setAutoStart(_ enabled: Bool) {}
func warmLayer(currentTime: Double, isPlaying: Bool) {}
@@ -67,18 +64,13 @@ import UIKit
// MARK: - Properties
private var pipController: AVPictureInPictureController?
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var containerView: UIView?
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
weak var delegate: MpvPipDelegate?
/// Pointer to the sample buffer layer for passing to mpv as `wid`
var layerPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
}
// MARK: - Initialization
override init() {
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
self.sampleBufferLayer = sampleBufferDisplayLayer
super.init()
setup()
}
@@ -93,18 +85,6 @@ import UIKit
print("[MpvPipController] Failed to configure audio session: \(error)")
}
// The sample buffer layer must be in a visible view hierarchy for
// isPictureInPicturePossible to become true.
if let window = ExternalDisplayManager.mainApplicationWindow() {
let view = UIView(frame: window.bounds)
view.clipsToBounds = true
view.isUserInteractionEnabled = false
sampleBufferLayer.frame = view.bounds
view.layer.addSublayer(sampleBufferLayer)
window.addSubview(view)
containerView = view
}
createPipController()
}
@@ -112,7 +92,7 @@ import UIKit
private var delegateHelper: AnyObject?
private func createPipController() {
guard #available(iOS 15.0, *) else { return }
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
let helper = PipDelegateHelper(controller: self)
let contentSource = AVPictureInPictureController.ContentSource(
sampleBufferDisplayLayer: sampleBufferLayer,
@@ -132,82 +112,23 @@ import UIKit
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
}
/// Push a black frame to the sample buffer layer so PiP has content
/// to display immediately (before vo_pip decodes the first real frame).
/// vo_avfoundation renders into the inline display layer, so PiP reuses
/// the current video frame instead of pushing synthetic buffers here.
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferIOSurfacePropertiesKey as String: [:]
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault, Int(width), Int(height),
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
)
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
// Fill with black
CVPixelBufferLockBaseAddress(pb, [])
if let base = CVPixelBufferGetBaseAddress(pb) {
memset(base, 0, CVPixelBufferGetDataSize(pb))
}
CVPixelBufferUnlockBaseAddress(pb, [])
// Use current timebase time for PTS (if available) so the frame isn't stale
let pts: CMTime
if let tb = sampleBufferLayer.controlTimebase {
pts = CMTimebaseGetTime(tb)
} else {
pts = CMTime(value: 0, timescale: 30)
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
// Create format description and sample buffer
var formatDesc: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescriptionOut: &formatDesc
)
guard let fmt = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pb,
formatDescription: fmt,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard let sb = sampleBuffer else { return }
// Set DisplayImmediately so it shows regardless of timebase timing
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
sb, createIfNecessary: true) as? [NSMutableDictionary],
let dict = attachments.first
{
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
}
sampleBufferLayer.enqueue(sb)
}
/// Sync the layer's controlTimebase with the actual playback position.
/// This makes the PiP progress bar show the correct time.
func syncTimebase(currentTime: Double, isPlaying: Bool) {
guard let timebase = sampleBufferLayer.controlTimebase else { return }
guard let timebase = sampleBufferLayer?.controlTimebase else { return }
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
CMTimebaseSetTime(timebase, time: cmTime)
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
}
/// Ensure the layer has a timebase and blank frame so the system considers
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
/// Ensure the layer has a timebase so the PiP progress UI can follow mpv.
func warmLayer(currentTime: Double, isPlaying: Bool) {
guard let sampleBufferLayer else { return }
if sampleBufferLayer.controlTimebase == nil {
var timebase: CMTimebase?
CMTimebaseCreateWithSourceClock(
@@ -220,7 +141,6 @@ import UIKit
}
}
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
pushBlankFrame()
}
// MARK: - Public API
@@ -241,19 +161,19 @@ import UIKit
var attempts = 0
func tryStart() {
let possible = pipController.isPictureInPicturePossible
let hasTimebase = sampleBufferLayer.controlTimebase != nil
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
let hasFrame: Bool
if !waitForFrame {
hasFrame = true // Skip frame check for auto-PiP
} else if #available(iOS 17.4, *) {
hasFrame = sampleBufferLayer.isReadyForDisplay
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
} else {
hasFrame = true
}
if possible && hasTimebase && hasFrame {
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
pipController.startPictureInPicture()
completion(true)
} else if attempts < 40 {
@@ -278,9 +198,7 @@ import UIKit
pipController?.invalidatePlaybackState()
}
/// Fully tear down PiP removes the container view from the window and
/// destroys the AVPictureInPictureController so the system can no longer
/// trigger auto-PiP after the player is disposed.
/// Fully tear down PiP without touching the shared inline display layer.
func teardown() {
pipController?.stopPictureInPicture()
if #available(iOS 14.2, *) {
@@ -288,16 +206,10 @@ import UIKit
}
pipController = nil
delegateHelper = nil
sampleBufferLayer.flushAndRemoveImage()
sampleBufferLayer.controlTimebase = nil
sampleBufferLayer.removeFromSuperlayer()
containerView?.removeFromSuperview()
containerView = nil
}
/// Flush enqueued sample buffers from the layer to free video frame memory
/// PiP shares the inline display layer, so cleanup must not flush it.
func flushLayer() {
sampleBufferLayer.flushAndRemoveImage()
}
}
+18 -56
View File
@@ -1,7 +1,7 @@
import Libmpv
import AVFoundation
import UIKit
/// Core MPV player using Metal rendering for iOS.
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
class MpvPlayerCore: MpvPlayerCoreBase {
private var containerView: UIView?
@@ -23,15 +23,15 @@ class MpvPlayerCore: MpvPlayerCoreBase {
container.backgroundColor = .clear
container.isUserInteractionEnabled = false
let layer = MpvMetalLayer()
let layer = MpvVideoLayer()
layer.frame = container.bounds
layer.contentsScale = window.screen.nativeScale
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
layer.videoGravity = .resizeAspect
container.layer.addSublayer(layer)
containerView = container
metalLayer = layer
videoLayer = layer
window.insertSubview(container, at: 0)
@@ -39,7 +39,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
container.removeFromSuperview()
metalLayer = nil
videoLayer = nil
containerView = nil
return false
}
@@ -54,40 +54,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
return true
}
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard mpv != nil else { return false }
print("[MpvPlayerCore] Switching to pip VO for PiP")
metalLayer?.removeFromSuperlayer()
setProperty("vid", value: "no")
setInt64PropertyAsync("wid", value: Int64(Int(bitPattern: layerPtr))) { _ in }
setProperty("vo", value: "pip")
setProperty("vid", value: "auto")
print("[MpvPlayerCore] Switched to pip VO successfully")
return true
}
func switchToGpuNextVO() -> Bool {
guard mpv != nil, let metalLayer else { return false }
print("[MpvPlayerCore] Switching back to gpu-next VO")
setProperty("vid", value: "no")
setInt64PropertyAsync("wid", value: Int64(Int(bitPattern: Unmanaged.passUnretained(metalLayer).toOpaque()))) { _ in
}
applyGpuNextOptions()
setProperty("vid", value: "auto")
if metalLayer.superlayer == nil, let containerView {
containerView.layer.addSublayer(metalLayer)
}
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
return true
}
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
func setVisible(_ visible: Bool) {
guard let containerView else { return }
@@ -99,28 +66,24 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer, let containerView else { return }
guard let videoLayer, let containerView else { return }
if let frame {
containerView.frame = frame
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
} else if let superview = containerView.superview {
containerView.frame = superview.bounds
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
} else if let window {
containerView.frame = window.bounds
metalLayer.frame = containerView.bounds
videoLayer.frame = containerView.bounds
}
mainBlankView?.frame = window?.bounds ?? .zero
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
videoLayer.contentsScale = scale
}
func externalDisplayDidChange() {
@@ -191,20 +154,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
mainBlankView = blankView
}
/// Nudge mpv to present the current paused frame after switching back from PiP.
/// Nudge mpv to present the current paused frame after leaving PiP.
func forceDraw() {
command(["seek", "0", "relative+exact"])
}
override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }
guard let videoLayer else { return }
var edrHeadroom: CGFloat = 1.0
#if os(iOS)
if #available(iOS 16.0, *) {
if #available(iOS 17.0, *) {
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
metalLayer.wantsExtendedDynamicRangeContent =
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
videoLayer.wantsExtendedDynamicRangeContent = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
}
#endif
@@ -221,8 +183,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
metalLayer = nil
videoLayer?.removeFromSuperlayer()
videoLayer = nil
containerView?.removeFromSuperview()
containerView = nil
mainBlankView?.removeFromSuperview()
+18 -18
View File
@@ -96,9 +96,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
// MARK: - PiP
private func ensurePipController() -> MpvPipController {
private func ensurePipController() -> MpvPipController? {
if let existing = pipController { return existing }
let controller = MpvPipController()
guard let layer = playerCore?.sampleBufferDisplayLayer else { return nil }
let controller = MpvPipController(sampleBufferDisplayLayer: layer)
controller.delegate = self
pipController = controller
return controller
@@ -158,7 +159,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
self.autoPipEnabled = ready
if ready {
let pip = self.ensurePipController()
guard let pip = self.ensurePipController() else {
result(nil)
return
}
pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore {
@@ -175,16 +179,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
/// Prepare the shared AVFoundation display layer for PiP display.
/// Returns the MpvPipController on success, nil on failure.
@discardableResult
private func switchToPipAndPrepare() -> MpvPipController? {
private func preparePip() -> MpvPipController? {
guard let playerCore = playerCore else { return nil }
let pip = ensurePipController()
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
guard let pip = ensurePipController() else { return nil }
pendingInlineRestoreAfterPip = false
playerCore.isPipStarting = true
pip.pushBlankFrame()
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
pip.invalidatePlaybackState()
return pip
@@ -205,10 +207,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
])
return
}
guard let pip = switchToPipAndPrepare() else {
guard let pip = preparePip() else {
result?([
"success": false, "errorCode": "vo_switch_failed",
"errorMessage": "Failed to switch VO",
"success": false, "errorCode": "pip_prepare_failed",
"errorMessage": "Failed to prepare PiP",
])
return
}
@@ -232,15 +234,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
playerCore?.isPipActive = false
isManualPipRequest = false
stopPipTimebaseSync()
pipController?.flushLayer()
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
if pause {
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
}
pendingInlineRestoreAfterPip = restoredInlineVO
pendingInlineRestoreAfterPip = true
if pendingInlineRestoreAfterPip {
if isSceneActive {
restoreInlinePlayerAfterPip()
@@ -393,11 +393,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
extension MpvPlayerPlugin: MpvPipDelegate {
func pipWillStart() {
// If PiP was system-initiated (not via our enterPip), switch VO now
// If PiP was system-initiated (not via our enterPip), prepare the shared layer now.
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO")
if switchToPipAndPrepare() == nil {
print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP")
print("[MpvPlayerPlugin] System-initiated PiP detected, preparing shared layer")
if preparePip() == nil {
print("[MpvPlayerPlugin] PiP preparation failed for system-initiated PiP")
pipController?.stopPip()
}
}