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
@@ -875,7 +875,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"
}
}
],
+11 -16
View File
@@ -13,8 +13,8 @@ protocol MpvPipDelegate: AnyObject {
}
/// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController).
/// This approach wraps the existing Metal rendering view in PiP no VO switching needed.
/// mpv continues rendering to its CAMetalLayer throughout PiP.
/// This approach wraps the existing video layer in PiP no VO switching needed.
/// mpv continues rendering to its AVFoundation display layer throughout PiP.
class MpvPipController: NSObject, PIPViewControllerDelegate {
// MARK: - Properties
@@ -39,23 +39,18 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
static var isSupported: Bool { true }
/// Enter PiP by wrapping the given Metal layer in a view and presenting it.
/// Enter PiP by wrapping the given video layer in a view and presenting it.
/// The layer continues receiving mpv frames no VO switch needed.
func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
func startPip(videoLayer: CALayer, window: NSWindow, aspectRatio: NSSize) {
guard !isActive else { return }
sourceWindow = window
// Create a layer-hosting wrapper view for the Metal layer.
// Create a layer-hosting wrapper view for the video layer.
// PIPViewController resizes the view (and its root layer) as the PiP window resizes.
let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio))
videoView.wantsLayer = true
videoView.layer = metalLayer
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
// Without this, the explicit drawableSize set by updateFrame() (main window size)
// persists and causes mpv/MoltenVK to render at the wrong resolution in PiP.
metalLayer.drawableSize = .zero
videoView.layer = videoLayer
// Create a view controller for PIPViewController
let vc = NSViewController()
@@ -97,16 +92,16 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
autoPipEnabled = enabled
}
/// Clean up after PiP closes detaches the Metal layer from the wrapper view
/// Clean up after PiP closes detaches the video layer from the wrapper view
/// so MpvPlayerCore can re-add it to the main window.
/// Returns the Metal layer that was hosted in PiP.
/// Returns the layer that was hosted in PiP.
@discardableResult
func detachLayer() -> CAMetalLayer? {
let metalLayer = pipVideoView?.layer as? CAMetalLayer
func detachLayer() -> CALayer? {
let videoLayer = pipVideoView?.layer
pipVideoView?.layer = CALayer() // detach before removing
pipVideoView = nil
pipVideoVC = nil
return metalLayer
return videoLayer
}
// MARK: - PIPViewControllerDelegate
+52 -46
View File
@@ -1,7 +1,8 @@
import AVFoundation
import Cocoa
import Libmpv
/// Core MPV player using Metal rendering.
/// Core MPV player using AVFoundation sample-buffer rendering.
class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: NSWindow?
@@ -22,27 +23,28 @@ class MpvPlayerCore: MpvPlayerCoreBase {
self.window = window
let layer = MpvMetalLayer()
let layer = MpvVideoLayer()
layer.frame = contentView.bounds
if let screen = window.screen ?? NSScreen.main {
layer.contentsScale = screen.backingScaleFactor
}
layer.framebufferOnly = true
layer.isOpaque = true
layer.backgroundColor = NSColor.black.cgColor
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
layer.videoGravity = .resizeAspect
metalLayer = layer
videoLayer = layer
updateEDRMode(sigPeak: lastSigPeak)
contentView.wantsLayer = true
contentView.layer?.addSublayer(layer)
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
print("[MpvPlayerCore] Video layer added, frame: \(layer.frame)")
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
metalLayer = nil
videoLayer = nil
return false
}
@@ -73,29 +75,23 @@ class MpvPlayerCore: MpvPlayerCoreBase {
override func configurePlatformMpvOptions() {
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no"))
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio"))
// Default fifo (vsync) mode mailbox was causing continuous GPU rendering even when paused
}
var videoLayer: CAMetalLayer? { metalLayer }
func reattachVideoLayer() {
guard let videoLayer, let contentView = window?.contentView else { return }
func reattachMetalLayer() {
guard let metalLayer, let contentView = window?.contentView else { return }
if metalLayer.superlayer == nil {
if videoLayer.superlayer == nil {
contentView.wantsLayer = true
contentView.layer?.insertSublayer(metalLayer, at: 0)
metalLayer.frame = contentView.bounds
contentView.layer?.insertSublayer(videoLayer, at: 0)
videoLayer.frame = contentView.bounds
if let screen = window?.screen ?? NSScreen.main {
metalLayer.contentsScale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: contentView.bounds.width * screen.backingScaleFactor,
height: contentView.bounds.height * screen.backingScaleFactor
)
videoLayer.contentsScale = screen.backingScaleFactor
}
}
print("[MpvPlayerCore] Metal layer reattached to window")
print("[MpvPlayerCore] Video layer reattached to window")
}
func forceDraw() {
@@ -106,22 +102,22 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private var pausedState = true
func setVisible(_ visible: Bool) {
guard let metalLayer, !isPipActive else { return }
guard let videoLayer, !isPipActive else { return }
isVisible = visible
isBackgrounded = !visible
if visible {
metalLayer.removeFromSuperlayer()
videoLayer.removeFromSuperlayer()
if let superlayer = window?.contentView?.layer {
superlayer.insertSublayer(metalLayer, at: 0)
superlayer.insertSublayer(videoLayer, at: 0)
}
beginPlaybackActivity()
} else {
endPlaybackActivity()
}
metalLayer.isHidden = !visible
videoLayer.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))")
}
@@ -135,38 +131,48 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer, !isPipActive else { return }
guard let videoLayer, !isPipActive else { return }
if let frame {
metalLayer.frame = frame
videoLayer.frame = frame
} else if let contentView = window?.contentView {
metalLayer.frame = contentView.bounds
videoLayer.frame = contentView.bounds
}
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
videoLayer.contentsScale = screen.backingScaleFactor
}
updateEDRMode(sigPeak: lastSigPeak)
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)")
print("[MpvPlayerCore] updateFrame: \(videoLayer.frame)")
}
override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }
guard let videoLayer else { return }
var edrHeadroom: CGFloat = 1.0
var currentHeadroom: CGFloat = 1.0
var potentialHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main {
edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
currentHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
potentialHeadroom = screen.maximumPotentialExtendedDynamicRangeColorComponentValue
}
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
let signalHeadroom = CGFloat(max(sigPeak, 1.0))
let contentHeadroom = min(signalHeadroom, potentialHeadroom)
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && potentialHeadroom > 1.0
if #available(macOS 26.0, *) {
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
videoLayer.contentsHeadroom = shouldEnableEDR ? contentHeadroom : 0
}
if #available(macOS 15.0, *) {
videoLayer.toneMapMode = shouldEnableEDR ? .ifSupported : .automatic
}
if #available(macOS 14.0, *) {
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}
print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), currentHeadroom: \(currentHeadroom), potentialHeadroom: \(potentialHeadroom), contentHeadroom: \(shouldEnableEDR ? contentHeadroom : 0))"
)
}
@@ -178,8 +184,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
NotificationCenter.default.removeObserver(self)
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
metalLayer = nil
videoLayer?.removeFromSuperlayer()
videoLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
@@ -199,19 +205,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func windowOcclusionDidChange(_ notification: Notification) {
guard let metalLayer, mpv != nil, !isPipActive else { return }
guard let videoLayer, mpv != nil, !isPipActive else { return }
let windowVisible = window?.occlusionState.contains(.visible) ?? true
if !windowVisible && !layerHiddenForOcclusion {
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
metalLayer.isHidden = true
print("[MpvPlayerCore] Window occluded - hiding video layer")
videoLayer.isHidden = true
layerHiddenForOcclusion = true
isBackgrounded = true
endPlaybackActivity()
} else if windowVisible && layerHiddenForOcclusion {
print("[MpvPlayerCore] Window visible - showing Metal layer")
print("[MpvPlayerCore] Window visible - showing video layer")
layerHiddenForOcclusion = false
metalLayer.isHidden = false
videoLayer.isHidden = false
isBackgrounded = false
if !pausedState {
beginPlaybackActivity()
+7 -7
View File
@@ -158,8 +158,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
/// Enter PiP by moving the Metal rendering layer to a PiP window.
/// No VO switching mpv keeps rendering to the same Metal layer.
/// Enter PiP by moving the AVFoundation video layer to a PiP window.
/// No VO switching mpv keeps rendering to the same layer.
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
guard let playerCore = playerCore else {
result?([
@@ -167,7 +167,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
])
return
}
guard let metalLayer = playerCore.videoLayer else {
guard let videoLayer = playerCore.videoLayer else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
return
}
@@ -191,7 +191,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
enteredPipViaAuto = !manual
playerCore.isPipActive = true
pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio)
pip.startPip(videoLayer: videoLayer, window: window, aspectRatio: aspectRatio)
pipChannel?.invokeMethod("onPipChanged", arguments: true)
result?(["success": true])
}
@@ -353,11 +353,11 @@ extension MpvPlayerPlugin: MpvPipDelegate {
playerCore?.isPipActive = false
enteredPipViaAuto = false
// Detach the Metal layer from the PiP wrapper view
// Detach the video layer from the PiP wrapper view
pipController?.detachLayer()
// Re-attach the Metal layer to the main window
playerCore?.reattachMetalLayer()
// Re-attach the video layer to the main window
playerCore?.reattachVideoLayer()
// Force a redraw if paused (prevents black frame after PiP exit)
if playerCore?.isPaused == true {