feat(apple): use AVFoundation mpv output
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user