fix(apple): use platform-specific mpv rendering

This commit is contained in:
edde746
2026-05-10 13:33:02 +02:00
parent 6f273b0aa8
commit bfb6aa01b9
12 changed files with 271 additions and 149 deletions
+5 -2
View File
@@ -5,8 +5,11 @@ class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
// Enable transparency for Metal layer behind Flutter
self.backgroundColor = NSColor.clear
// Keep the window itself opaque so WindowServer does not have to blend the
// whole video window with the desktop every frame. Flutter stays clear so
// the native video layer behind it remains visible.
self.isOpaque = true
self.backgroundColor = NSColor.black
flutterViewController.backgroundColor = NSColor.clear
let windowFrame = self.frame
+16 -11
View File
@@ -1,4 +1,5 @@
import Cocoa
import QuartzCore
/// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject {
@@ -13,8 +14,8 @@ protocol MpvPipDelegate: AnyObject {
}
/// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController).
/// This approach wraps the existing video layer in PiP no VO switching needed.
/// mpv continues rendering to its AVFoundation display layer throughout PiP.
/// This approach wraps the existing Metal rendering layer in PiP no VO switching needed.
/// mpv continues rendering to its CAMetalLayer throughout PiP.
class MpvPipController: NSObject, PIPViewControllerDelegate {
// MARK: - Properties
@@ -39,18 +40,22 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
static var isSupported: Bool { true }
/// Enter PiP by wrapping the given video layer in a view and presenting it.
/// Enter PiP by wrapping the given Metal layer in a view and presenting it.
/// The layer continues receiving mpv frames no VO switch needed.
func startPip(videoLayer: CALayer, window: NSWindow, aspectRatio: NSSize) {
func startPip(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
guard !isActive else { return }
sourceWindow = window
// Create a layer-hosting wrapper view for the video layer.
// Create a layer-hosting wrapper view for the Metal 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 = videoLayer
videoView.layer = metalLayer
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
// Without this, the explicit main-window drawableSize persists in PiP.
metalLayer.drawableSize = .zero
// Create a view controller for PIPViewController
let vc = NSViewController()
@@ -92,16 +97,16 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
autoPipEnabled = enabled
}
/// Clean up after PiP closes detaches the video layer from the wrapper view
/// Clean up after PiP closes detaches the Metal layer from the wrapper view
/// so MpvPlayerCore can re-add it to the main window.
/// Returns the layer that was hosted in PiP.
/// Returns the Metal layer that was hosted in PiP.
@discardableResult
func detachLayer() -> CALayer? {
let videoLayer = pipVideoView?.layer
func detachLayer() -> CAMetalLayer? {
let metalLayer = pipVideoView?.layer as? CAMetalLayer
pipVideoView?.layer = CALayer() // detach before removing
pipVideoView = nil
pipVideoVC = nil
return videoLayer
return metalLayer
}
// MARK: - PIPViewControllerDelegate
+91 -52
View File
@@ -1,8 +1,8 @@
import AVFoundation
import Cocoa
import Libmpv
import QuartzCore
/// Core MPV player using AVFoundation sample-buffer rendering.
/// Core MPV player using Metal rendering on macOS.
class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: NSWindow?
@@ -23,28 +23,33 @@ class MpvPlayerCore: MpvPlayerCoreBase {
self.window = window
let layer = MpvVideoLayer()
let layer = MpvMetalLayer()
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
videoLayer = layer
updateEDRMode(sigPeak: lastSigPeak)
metalLayer = layer
contentView.wantsLayer = true
contentView.layer?.addSublayer(layer)
guard let contentLayer = contentView.layer else {
print("[MpvPlayerCore] No content layer")
metalLayer = nil
return false
}
attachMetalLayer(to: contentLayer, frame: contentView.bounds)
updateEDRMode(sigPeak: lastSigPeak)
print("[MpvPlayerCore] Video layer added, frame: \(layer.frame)")
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
videoLayer = nil
metalLayer = nil
return false
}
@@ -75,23 +80,18 @@ 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"))
}
func reattachVideoLayer() {
guard let videoLayer, let contentView = window?.contentView else { return }
func reattachMetalLayer() {
guard let contentView = window?.contentView else { return }
if videoLayer.superlayer == nil {
contentView.wantsLayer = true
contentView.layer?.insertSublayer(videoLayer, at: 0)
videoLayer.frame = contentView.bounds
if let screen = window?.screen ?? NSScreen.main {
videoLayer.contentsScale = screen.backingScaleFactor
}
contentView.wantsLayer = true
if let contentLayer = contentView.layer {
attachMetalLayer(to: contentLayer, frame: contentView.bounds)
}
print("[MpvPlayerCore] Video layer reattached to window")
print("[MpvPlayerCore] Metal layer reattached to window")
}
func forceDraw() {
@@ -102,22 +102,24 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private var pausedState = true
func setVisible(_ visible: Bool) {
guard let videoLayer, !isPipActive else { return }
guard metalLayer != nil, !isPipActive else { return }
isVisible = visible
isBackgrounded = !visible
if visible {
videoLayer.removeFromSuperlayer()
if let superlayer = window?.contentView?.layer {
superlayer.insertSublayer(videoLayer, at: 0)
if let contentView = window?.contentView {
contentView.wantsLayer = true
if let superlayer = contentView.layer {
attachMetalLayer(to: superlayer, frame: contentView.bounds)
}
}
beginPlaybackActivity()
} else {
endPlaybackActivity()
}
videoLayer.isHidden = !visible
setMetalLayerHidden(!visible)
print("[MpvPlayerCore] setVisible(\(visible))")
}
@@ -131,46 +133,41 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
func updateFrame(_ frame: CGRect? = nil) {
guard let videoLayer, !isPipActive else { return }
guard let metalLayer, !isPipActive else { return }
let targetFrame: CGRect
if let frame {
videoLayer.frame = frame
targetFrame = frame
} else if let contentView = window?.contentView {
videoLayer.frame = contentView.bounds
targetFrame = contentView.bounds
} else {
return
}
if let screen = window?.screen ?? NSScreen.main {
videoLayer.contentsScale = screen.backingScaleFactor
withoutLayerAnimations {
metalLayer.frame = targetFrame
updateDrawableSize(for: metalLayer)
}
updateEDRMode(sigPeak: lastSigPeak)
}
override func updateEDRMode(sigPeak: Double) {
guard let videoLayer else { return }
guard let metalLayer else { return }
let hdrEnabled = self.hdrEnabled
var currentHeadroom: CGFloat = 1.0
var potentialHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main {
currentHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
potentialHeadroom = screen.maximumPotentialExtendedDynamicRangeColorComponentValue
}
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
withoutLayerAnimations {
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}
print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), potentialHeadroom: \(potentialHeadroom))"
)
}
func dispose() {
@@ -181,8 +178,8 @@ class MpvPlayerCore: MpvPlayerCoreBase {
NotificationCenter.default.removeObserver(self)
disposeSharedState(destroySynchronously: false)
videoLayer?.removeFromSuperlayer()
videoLayer = nil
metalLayer?.removeFromSuperlayer()
metalLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
@@ -202,19 +199,19 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func windowOcclusionDidChange(_ notification: Notification) {
guard let videoLayer, mpv != nil, !isPipActive else { return }
guard metalLayer != nil, mpv != nil, !isPipActive else { return }
let windowVisible = window?.occlusionState.contains(.visible) ?? true
if !windowVisible && !layerHiddenForOcclusion {
print("[MpvPlayerCore] Window occluded - hiding video layer")
videoLayer.isHidden = true
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
setMetalLayerHidden(true)
layerHiddenForOcclusion = true
isBackgrounded = true
endPlaybackActivity()
} else if windowVisible && layerHiddenForOcclusion {
print("[MpvPlayerCore] Window visible - showing video layer")
print("[MpvPlayerCore] Window visible - showing Metal layer")
layerHiddenForOcclusion = false
videoLayer.isHidden = false
setMetalLayerHidden(!isVisible)
isBackgrounded = false
if !pausedState {
beginPlaybackActivity()
@@ -237,4 +234,46 @@ class MpvPlayerCore: MpvPlayerCoreBase {
self.playbackActivity = nil
print("[MpvPlayerCore] Ended playback activity assertion")
}
private func attachMetalLayer(to superlayer: CALayer, frame: CGRect) {
guard let metalLayer else { return }
withoutLayerAnimations {
superlayer.backgroundColor = NSColor.black.cgColor
superlayer.isOpaque = true
let needsReorder = superlayer.sublayers?.first !== metalLayer
if metalLayer.superlayer !== superlayer || needsReorder {
metalLayer.removeFromSuperlayer()
superlayer.insertSublayer(metalLayer, at: 0)
}
metalLayer.frame = frame
updateDrawableSize(for: metalLayer)
}
}
private func updateDrawableSize(for metalLayer: CAMetalLayer) {
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
}
}
private func setMetalLayerHidden(_ hidden: Bool) {
withoutLayerAnimations {
metalLayer?.isHidden = hidden
}
}
private func withoutLayerAnimations(_ updates: () -> Void) {
CATransaction.begin()
CATransaction.setDisableActions(true)
updates()
CATransaction.commit()
}
}
+7 -7
View File
@@ -158,7 +158,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
}
/// Enter PiP by moving the AVFoundation video layer to a PiP window.
/// Enter PiP by moving the Metal rendering 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 {
@@ -167,8 +167,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
])
return
}
guard let videoLayer = playerCore.videoLayer else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
guard let metalLayer = playerCore.metalLayer else {
result?(["success": false, "errorCode": "failed", "errorMessage": "No Metal layer"])
return
}
guard let window = findFlutterWindow()?.0 else {
@@ -191,7 +191,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
enteredPipViaAuto = !manual
playerCore.isPipActive = true
pip.startPip(videoLayer: videoLayer, window: window, aspectRatio: aspectRatio)
pip.startPip(metalLayer: metalLayer, 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 video layer from the PiP wrapper view
// Detach the Metal layer from the PiP wrapper view
pipController?.detachLayer()
// Re-attach the video layer to the main window
playerCore?.reattachVideoLayer()
// Re-attach the Metal layer to the main window
playerCore?.reattachMetalLayer()
// Force a redraw if paused (prevents black frame after PiP exit)
if playerCore?.isPaused == true {