From bddf72323133ad1190709ed418b99fc581318de7 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Sun, 21 Jun 2026 16:29:19 +0200 Subject: [PATCH 1/2] fix(macos): HDR improvements and prevent separate mpv window --- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 152 ++++++------------ .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 46 ++++-- 2 files changed, 78 insertions(+), 120 deletions(-) diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 7c479f8d9..b0ef16c6d 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -1,5 +1,6 @@ import Cocoa import Libmpv +import Metal import QuartzCore /// Core MPV player using Metal rendering on macOS. @@ -7,7 +8,6 @@ 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 { @@ -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 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 @@ -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") @@ -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) + 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") @@ -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 @@ -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))") } @@ -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() { @@ -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() @@ -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) diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 5fbfb4e91..b30e71009 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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 { @@ -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 @@ -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 } @@ -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) } @@ -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) @@ -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" @@ -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) } From faad5b0c2a4a898db1bacfd6b95ef68364ed1864 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Sun, 21 Jun 2026 17:34:00 +0200 Subject: [PATCH 2/2] fix(macos): restore EDR layer handling and dynamic target-peak for HDR toggle --- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 33 +++++++++++++------ .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 4 +-- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index b0ef16c6d..927522602 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -82,14 +82,6 @@ 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() { @@ -186,8 +178,29 @@ class MpvPlayerCore: MpvPlayerCoreBase { } override func updateEDRMode(sigPeak: Double) { - // macOS: mpv/gpu-next manages EDR on its own CAMetalLayer via - // target-colorspace-hint. No manual EDR toggle needed. + 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 + } + + if shouldEnableEDR { + let peakNits = Int(potentialHeadroom * 80) + setPropertyAsync("target-peak", value: "\(peakNits)") { _ in } + } else { + setPropertyAsync("target-peak", value: "auto") { _ in } + } + + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), potentialHeadroom: \(potentialHeadroom))" + ) } func dispose() { diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index b30e71009..e44681e99 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -574,8 +574,8 @@ class MpvPlayerCoreBase: NSObject { #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 } + setRawStringPropertyAsync("tone-mapping", value: "auto") { _ in } + setRawStringPropertyAsync("gamut-mapping-mode", value: "auto") { _ in } #endif DispatchQueue.main.async {