diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 07eef24b..58230274 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -11,7 +11,7 @@ PODS: - file_picker (0.0.1): - Flutter - Flutter (1.0.0) - - os_media_controls (0.0.1): + - os_media_controls (0.2.1): - Flutter - package_info_plus (0.4.5): - Flutter @@ -113,7 +113,7 @@ SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - os_media_controls: 94cc278f5802b82b2d6373003aeb511f96718b27 + os_media_controls: 3b06fe2dc0977dfa94f9c1dd73df7a55022d202f package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c10b7832..1509cb52 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -781,7 +781,7 @@ repositoryURL = "https://github.com/edde746/MPVKit"; requirement = { kind = revision; - revision = e9dc70092d8763c5cbee5ea72906cebc42f8dac8; + revision = 86dcf97f04e572abb5641e74780251a1d5634ff3; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7be8a992..970d1468 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "e9dc70092d8763c5cbee5ea72906cebc42f8dac8" + "revision" : "86dcf97f04e572abb5641e74780251a1d5634ff3" } } ], diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7be8a992..970d1468 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "e9dc70092d8763c5cbee5ea72906cebc42f8dac8" + "revision" : "86dcf97f04e572abb5641e74780251a1d5634ff3" } } ], diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index a0291091..9a0ffaf4 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -1,4 +1,5 @@ import AVFoundation +import QuartzCore import UIKit /// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS. @@ -20,12 +21,13 @@ class MpvPlayerCore: MpvPlayerCoreBase { self.window = window let container = UIView(frame: window.bounds) - container.backgroundColor = .clear + container.backgroundColor = .black container.isUserInteractionEnabled = false let layer = MpvVideoLayer() layer.frame = container.bounds layer.contentsScale = window.screen.nativeScale + layer.isOpaque = true layer.backgroundColor = UIColor.black.cgColor layer.videoGravity = .resizeAspect @@ -57,33 +59,35 @@ class MpvPlayerCore: MpvPlayerCoreBase { var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer } func setVisible(_ visible: Bool) { - guard let containerView else { return } + guard containerView != nil else { return } isVisible = visible if visible { refreshExternalDisplayAttachment() } - containerView.isHidden = !visible + setContainerHidden(!visible) if !visible { mainBlankView?.isHidden = true } } func updateFrame(_ frame: CGRect? = nil) { guard let videoLayer, let containerView else { return } - if let frame { - containerView.frame = frame - videoLayer.frame = containerView.bounds - } else if let superview = containerView.superview { - containerView.frame = superview.bounds - videoLayer.frame = containerView.bounds - } else if let window { - containerView.frame = window.bounds - videoLayer.frame = containerView.bounds + withoutLayerAnimations { + if let frame { + containerView.frame = frame + videoLayer.frame = containerView.bounds + } else if let superview = containerView.superview { + containerView.frame = superview.bounds + videoLayer.frame = containerView.bounds + } else if let window { + containerView.frame = window.bounds + videoLayer.frame = containerView.bounds + } + + mainBlankView?.frame = window?.bounds ?? .zero + + let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main + let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale + videoLayer.contentsScale = scale } - - mainBlankView?.frame = window?.bounds ?? .zero - - let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main - let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale - videoLayer.contentsScale = scale } func externalDisplayDidChange() { @@ -105,7 +109,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { setMainBlankViewVisible(false) } - containerView.isHidden = !isVisible + setContainerHidden(!isVisible) updateFrame() } @@ -122,12 +126,17 @@ class MpvPlayerCore: MpvPlayerCoreBase { private func moveContainerView(to superview: UIView) { guard let containerView else { return } - if containerView.superview !== superview { - containerView.removeFromSuperview() + withoutLayerAnimations { + if containerView.superview !== superview { + containerView.removeFromSuperview() + superview.insertSubview(containerView, at: 0) + } else if superview.subviews.first !== containerView { + superview.insertSubview(containerView, at: 0) + } + + containerView.frame = superview.bounds + containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] } - containerView.frame = superview.bounds - containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - superview.insertSubview(containerView, at: 0) } private func setMainBlankViewVisible(_ visible: Bool) { @@ -138,22 +147,37 @@ class MpvPlayerCore: MpvPlayerCoreBase { } let blankView = mainBlankView ?? UIView(frame: window.bounds) - blankView.backgroundColor = .black - blankView.isUserInteractionEnabled = false - blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - blankView.frame = window.bounds + withoutLayerAnimations { + blankView.backgroundColor = .black + blankView.isUserInteractionEnabled = false + blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + blankView.frame = window.bounds - if blankView.superview !== window { - blankView.removeFromSuperview() - window.insertSubview(blankView, at: 0) - } else { - window.insertSubview(blankView, at: 0) + if blankView.superview !== window { + blankView.removeFromSuperview() + window.insertSubview(blankView, at: 0) + } else if window.subviews.first !== blankView { + window.insertSubview(blankView, at: 0) + } + + blankView.isHidden = false } - - blankView.isHidden = false mainBlankView = blankView } + private func setContainerHidden(_ hidden: Bool) { + withoutLayerAnimations { + containerView?.isHidden = hidden + } + } + + private func withoutLayerAnimations(_ updates: () -> Void) { + CATransaction.begin() + CATransaction.setDisableActions(true) + updates() + CATransaction.commit() + } + /// Nudge mpv to present the current paused frame after leaving PiP. func forceDraw() { command(["seek", "0", "relative+exact"]) @@ -167,7 +191,10 @@ class MpvPlayerCore: MpvPlayerCoreBase { #if os(iOS) if #available(iOS 17.0, *) { edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 - videoLayer.wantsExtendedDynamicRangeContent = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + withoutLayerAnimations { + videoLayer.wantsExtendedDynamicRangeContent = + hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + } } #endif diff --git a/lib/services/ambient_lighting_service.dart b/lib/services/ambient_lighting_service.dart index 827b9efc..8e1f068a 100644 --- a/lib/services/ambient_lighting_service.dart +++ b/lib/services/ambient_lighting_service.dart @@ -27,7 +27,7 @@ class AmbientLightingService { AmbientLightingService(this._player); bool get isEnabled => _enabled; - bool get isSupported => _player.playerType == 'mpv' && !Platform.isIOS && !Platform.isMacOS; + bool get isSupported => _player.playerType == 'mpv' && !Platform.isIOS; /// Enable ambient lighting effect. /// diff --git a/lib/services/shader_service.dart b/lib/services/shader_service.dart index 5d49a59f..a532ffc9 100644 --- a/lib/services/shader_service.dart +++ b/lib/services/shader_service.dart @@ -22,7 +22,7 @@ class ShaderService { ShaderPreset get currentPreset => _currentPreset; - static bool get isPlatformSupported => !Platform.isIOS && !Platform.isMacOS; + static bool get isPlatformSupported => !Platform.isIOS; /// Check if the player is MPV (shaders are MPV-only) bool get isSupported => _player.playerType == 'mpv' && isPlatformSupported; diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 7c203119..de62a9b9 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -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 diff --git a/macos/Runner/MpvPlayer/MpvPipController.swift b/macos/Runner/MpvPlayer/MpvPipController.swift index 9b0934d3..ae2fd4c2 100644 --- a/macos/Runner/MpvPlayer/MpvPipController.swift +++ b/macos/Runner/MpvPlayer/MpvPipController.swift @@ -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 diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 7ec46ce5..593e79f0 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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() + } } diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index 189d1c1e..1fd1ad04 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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 { diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index d2243bdd..4cac6e71 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -14,7 +14,35 @@ protocol MpvPlayerDelegate: AnyObject { func onEvent(name: String, data: [String: Any]?) } -class MpvVideoLayer: AVSampleBufferDisplayLayer {} +#if os(macOS) + // Workaround for MoltenVK problems that cause flicker. + // https://github.com/mpv-player/mpv/pull/13651 + class MpvMetalLayer: CAMetalLayer { + override var drawableSize: CGSize { + get { super.drawableSize } + set { + if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { + super.drawableSize = newValue + } + } + } + + override var wantsExtendedDynamicRangeContent: Bool { + get { super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.async { + super.wantsExtendedDynamicRangeContent = newValue + } + } + } + } + } +#else + class MpvVideoLayer: AVSampleBufferDisplayLayer {} +#endif /// Safely convert a C string to Swift String with UTF-8 validation. /// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. @@ -36,7 +64,11 @@ func safeString(_ cstr: UnsafePointer) -> String { class MpvPlayerCoreBase: NSObject { weak var delegate: MpvPlayerDelegate? - var videoLayer: MpvVideoLayer? + #if os(macOS) + var metalLayer: MpvMetalLayer? + #else + var videoLayer: MpvVideoLayer? + #endif var mpv: OpaquePointer? var isInitialized = false var isDisposing = false @@ -100,7 +132,11 @@ class MpvPlayerCoreBase: NSObject { func updateEDRMode(sigPeak: Double) {} func setupMpv() -> Bool { - guard let videoLayer else { return false } + #if os(macOS) + guard let renderLayer = metalLayer else { return false } + #else + guard let renderLayer = videoLayer else { return false } + #endif mpv = mpv_create() guard let mpv else { @@ -114,7 +150,7 @@ class MpvPlayerCoreBase: NSObject { checkError(mpv_request_log_messages(mpv, "warn")) #endif - var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(videoLayer).toOpaque())) + var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque())) checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) applySharedMpvOptions() configurePlatformMpvOptions() @@ -167,8 +203,8 @@ class MpvPlayerCoreBase: NSObject { } #endif - if isAvFoundationManagedProperty(name) { - print("[MpvPlayerCore] Ignoring managed AVFoundation property: \(name)=\(value)") + if isManagedRendererProperty(name) { + print("[MpvPlayerCore] Ignoring managed renderer property: \(name)=\(value)") completion(.success(())) return } @@ -356,48 +392,60 @@ class MpvPlayerCoreBase: NSObject { private func applySharedMpvOptions() { guard let mpv else { return } - checkError(mpv_set_option_string(mpv, "vo", "avfoundation")) - #if targetEnvironment(simulator) - checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no")) - checkError(mpv_set_option_string(mpv, "hwdec", "no")) - #else - checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "yes")) + #if os(macOS) + checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) + 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")) + #else + checkError(mpv_set_option_string(mpv, "vo", "avfoundation")) + #if targetEnvironment(simulator) + checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no")) + checkError(mpv_set_option_string(mpv, "hwdec", "no")) + #else + checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "yes")) + checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + #endif #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", "yes")) } - private func isAvFoundationManagedProperty(_ name: String) -> Bool { + private func isManagedRendererProperty(_ name: String) -> Bool { name == "vo" || name == "wid" || name == "gpu-api" || name == "gpu-context" + || name == "avfoundation-composite-osd" } private func updateVideoGravityIfNeeded(name: String, value: String) { - let gravity: AVLayerVideoGravity - cacheLock.lock() - switch name { - case "panscan": - currentPanscan = Double(value) ?? 0 - case "video-aspect-override": - aspectOverrideActive = value != "no" && value != "-1" && value != "0" - default: - cacheLock.unlock() + #if os(macOS) return - } + #else + let gravity: AVLayerVideoGravity + cacheLock.lock() + switch name { + case "panscan": + currentPanscan = Double(value) ?? 0 + case "video-aspect-override": + aspectOverrideActive = value != "no" && value != "-1" && value != "0" + default: + cacheLock.unlock() + return + } - if aspectOverrideActive { - gravity = .resize - } else if currentPanscan > 0 { - gravity = .resizeAspectFill - } else { - gravity = .resizeAspect - } - cacheLock.unlock() + if aspectOverrideActive { + gravity = .resize + } else if currentPanscan > 0 { + gravity = .resizeAspectFill + } else { + gravity = .resizeAspect + } + cacheLock.unlock() - DispatchQueue.main.async { [weak self] in - self?.videoLayer?.videoGravity = gravity - } + DispatchQueue.main.async { [weak self] in + self?.videoLayer?.videoGravity = gravity + } + #endif } private func cancelPendingRequests() {