fix(apple): use platform-specific mpv rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user