Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 49 additions & 103 deletions macos/Runner/MpvPlayer/MpvPlayerCore.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import Cocoa
import Libmpv
import Metal
import QuartzCore

/// Core MPV player using Metal rendering on macOS.
class MpvPlayerCore: MpvPlayerCoreBase {

private weak var window: NSWindow?
private var playbackActivity: NSObjectProtocol?
private var layerHiddenForOcclusion = false
private var isDisposed = false

func initialize(in window: NSWindow) -> Bool {
Expand All @@ -23,14 +23,15 @@ class MpvPlayerCore: MpvPlayerCoreBase {

self.window = window

// Create and configure Metal layer for mpv rendering
let layer = MpvMetalLayer()
layer.device = MTLCreateSystemDefaultDevice()
layer.frame = contentView.bounds
Comment on lines +26 to 29
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]

metalLayer = layer
Expand All @@ -41,10 +42,10 @@ class MpvPlayerCore: MpvPlayerCoreBase {
metalLayer = nil
return false
}
attachMetalLayer(to: contentLayer, frame: contentView.bounds)
updateEDRMode(sigPeak: lastSigPeak)

print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
withoutLayerAnimations {
contentLayer.insertSublayer(layer, at: 0)
layer.frame = contentView.bounds
}

guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
Expand Down Expand Up @@ -81,14 +82,35 @@ class MpvPlayerCore: MpvPlayerCoreBase {
override func configurePlatformMpvOptions() {
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio"))

let screen = window?.screen ?? NSScreen.main
let headroom = screen?.maximumPotentialExtendedDynamicRangeColorComponentValue ?? 1.0
if headroom > 1.0 {
let peakNits = Int(headroom * 80)
checkError(mpv_set_option_string(mpv, "target-peak", "\(peakNits)"))
print("[MpvPlayerCore] Display supports EDR with headroom \(headroom), setting target-peak=\(peakNits) nits")
}
}

func reattachMetalLayer() {
guard let contentView = window?.contentView else { return }
guard let metalLayer, let contentView = window?.contentView else { return }

contentView.wantsLayer = true
if let contentLayer = contentView.layer {
attachMetalLayer(to: contentLayer, frame: contentView.bounds)
if let superlayer = contentView.layer, metalLayer.superlayer !== superlayer {
withoutLayerAnimations {
superlayer.backgroundColor = NSColor.black.cgColor
superlayer.isOpaque = true
superlayer.insertSublayer(metalLayer, at: 0)
Comment on lines 90 to +95
metalLayer.frame = contentView.bounds
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: contentView.bounds.width * scale,
height: contentView.bounds.height * scale
)
}
}
}

print("[MpvPlayerCore] Metal layer reattached to window")
Expand All @@ -107,9 +129,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {

if visible && isVisible && !shouldRestoreOnWindowVisible {
isBackgrounded = false
if metalLayer?.isHidden == true {
setMetalLayerHidden(false)
}
metalLayer?.isHidden = false
beginPlaybackActivity()
print("[MpvPlayerCore] setVisible(true) skipped - already visible")
return
Expand All @@ -121,18 +141,13 @@ class MpvPlayerCore: MpvPlayerCoreBase {

if visible {
shouldRestoreOnWindowVisible = false
if let contentView = window?.contentView {
contentView.wantsLayer = true
if let superlayer = contentView.layer {
attachMetalLayer(to: superlayer, frame: contentView.bounds)
}
}
reattachMetalLayer()
beginPlaybackActivity()
} else {
endPlaybackActivity()
}

setMetalLayerHidden(!visible)
metalLayer?.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible), restoreOnWindowVisible: \(restoreOnWindowVisible))")
}

Expand All @@ -159,28 +174,20 @@ class MpvPlayerCore: MpvPlayerCoreBase {

withoutLayerAnimations {
metalLayer.frame = targetFrame
updateDrawableSize(for: metalLayer)
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.contentsScale = scale
metalLayer.drawableSize = CGSize(
width: targetFrame.width * scale,
height: targetFrame.height * scale
)
}
}
updateEDRMode(sigPeak: lastSigPeak)
}

override func updateEDRMode(sigPeak: Double) {
guard let metalLayer else { return }

let hdrEnabled = self.hdrEnabled
var potentialHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main {
potentialHeadroom = screen.maximumPotentialExtendedDynamicRangeColorComponentValue
}

let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && potentialHeadroom > 1.0
withoutLayerAnimations {
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
}

print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), potentialHeadroom: \(potentialHeadroom))"
)
// macOS: mpv/gpu-next manages EDR on its own CAMetalLayer via
// target-colorspace-hint. No manual EDR toggle needed.
}

func dispose() {
Expand Down Expand Up @@ -212,23 +219,15 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}

@objc private func windowOcclusionDidChange(_ notification: Notification) {
guard metalLayer != nil, mpv != nil, !isPipActive else { return }
guard mpv != nil, !isPipActive else { return }

let windowVisible = window?.occlusionState.contains(.visible) ?? true
if !windowVisible && !layerHiddenForOcclusion {
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
setMetalLayerHidden(true)
layerHiddenForOcclusion = true
if !windowVisible {
print("[MpvPlayerCore] Window occluded")
isBackgrounded = true
endPlaybackActivity()
} else if windowVisible && layerHiddenForOcclusion {
print("[MpvPlayerCore] Window visible - showing Metal layer")
layerHiddenForOcclusion = false
if shouldRestoreOnWindowVisible {
restoreMetalLayerAfterOcclusion()
} else {
setMetalLayerHidden(!isVisible)
}
} else {
print("[MpvPlayerCore] Window visible")
isBackgrounded = false
if !pausedState {
beginPlaybackActivity()
Expand All @@ -252,59 +251,6 @@ class MpvPlayerCore: MpvPlayerCoreBase {
print("[MpvPlayerCore] Ended playback activity assertion")
}

private func restoreMetalLayerAfterOcclusion() {
if let metalLayer, let contentView = window?.contentView {
contentView.wantsLayer = true
if let superlayer = contentView.layer {
let targetFrame = contentView.bounds
let needsAttach = metalLayer.superlayer !== superlayer || superlayer.sublayers?.first !== metalLayer
if needsAttach {
attachMetalLayer(to: superlayer, frame: targetFrame)
} else if !metalLayer.frame.equalTo(targetFrame) {
updateFrame(targetFrame)
}
}
}
isVisible = true
shouldRestoreOnWindowVisible = false
setMetalLayerHidden(false)
}

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)
Expand Down
46 changes: 29 additions & 17 deletions shared/apple/MpvPlayer/MpvPlayerCoreBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ protocol MpvPlayerDelegate: AnyObject {
func onEvent(name: String, data: [String: Any]?)
}

#if os(macOS)
// Workaround for MoltenVK problems that cause flicker.
// https://github.com/mpv-player/mpv/pull/13651
class MpvMetalLayer: CAMetalLayer {
#if os(macOS)
class MpvMetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { super.drawableSize }
set {
Expand Down Expand Up @@ -84,7 +82,7 @@ class MpvPlayerCoreBase: NSObject {
weak var delegate: MpvPlayerDelegate?

#if os(macOS)
var metalLayer: MpvMetalLayer?
var metalLayer: CAMetalLayer?
#else
var videoLayer: MpvVideoLayer?
#endif
Expand Down Expand Up @@ -336,6 +334,16 @@ class MpvPlayerCoreBase: NSObject {
}
}

#if os(macOS)
private func configureMoltenVKPlacementHeaps() {
guard let device = MTLCreateSystemDefaultDevice() else { return }
let supportsPlacementHeaps = device.supportsFamily(.apple2) || device.supportsFamily(.mac2)
if !supportsPlacementHeaps {
setenv("MVK_CONFIG_USE_MTLHEAP", "0", 1)
}
}
#endif

func setupMpv() -> Bool {
#if os(macOS)
guard let renderLayer = metalLayer else { return false }
Expand Down Expand Up @@ -559,10 +567,17 @@ class MpvPlayerCoreBase: NSObject {

setRawStringPropertyAsync(
"target-colorspace-hint",
value: enabled ? "auto" : "no",
value: enabled ? "yes" : "no",
completion: completion ?? { _ in }
)

#if os(macOS)
setRawStringPropertyAsync("target-prim", value: enabled ? "auto" : "bt.709") { _ in }
setRawStringPropertyAsync("target-trc", value: enabled ? "auto" : "bt.1886") { _ in }
setRawStringPropertyAsync("tone-mapping", value: enabled ? "clip" : "auto") { _ in }
setRawStringPropertyAsync("gamut-mapping-mode", value: enabled ? "clip" : "auto") { _ in }
#endif

DispatchQueue.main.async {
self.updateEDRMode(sigPeak: sigPeak)
}
Expand Down Expand Up @@ -755,6 +770,11 @@ class MpvPlayerCoreBase: NSObject {
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
checkError(mpv_set_option_string(mpv, "tone-mapping", "auto"))
checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "auto"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto"))
checkError(mpv_set_option_string(mpv, "target-prim", "auto"))
checkError(mpv_set_option_string(mpv, "target-trc", "auto"))
#else
checkError(mpv_set_option_string(mpv, "vo", "avfoundation"))
#if targetEnvironment(simulator)
Expand All @@ -770,25 +790,15 @@ class MpvPlayerCoreBase: NSObject {
checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
#endif
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto"))
#endif
checkError(mpv_set_option_string(mpv, "hwdec-codecs", "all"))
checkError(mpv_set_option_string(mpv, "hwdec-software-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto"))
// Pause on the last frame at EOF instead of unloading the file, so seeking
// back after the video ends still works (matches Linux/Windows).
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
}

#if os(macOS)
private func configureMoltenVKPlacementHeaps() {
guard let device = MTLCreateSystemDefaultDevice() else { return }
let supportsPlacementHeaps = device.supportsFamily(.apple2) || device.supportsFamily(.mac2)
if !supportsPlacementHeaps {
setenv("MVK_CONFIG_USE_MTLHEAP", "0", 1)
}
}
#endif

private func isManagedRendererProperty(_ name: String) -> Bool {
name == "vo" || name == "wid" || name == "gpu-api" || name == "gpu-context"
|| name == "avfoundation-composite-osd" || name == "avfoundation-pip-composite-osd"
Expand Down Expand Up @@ -981,7 +991,9 @@ class MpvPlayerCoreBase: NSObject {
print("[MpvPlayerCore] MPV shutdown event")

case MPV_EVENT_PLAYBACK_RESTART:
let sigPeak = lastSigPeak
DispatchQueue.main.async {
self.updateEDRMode(sigPeak: sigPeak)
self.delegate?.onEvent(name: "playback-restart", data: nil)
}

Expand Down