chore: add native formatting checks
This commit is contained in:
@@ -2,14 +2,14 @@ import Cocoa
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
/// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI).
|
||||
func pipDidStop(restored: Bool)
|
||||
/// Forward play/pause commands from PiP overlay to mpv
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
/// Query whether mpv is currently playing
|
||||
var isPipPlaying: Bool { get }
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
/// Called when PiP stops. `restored` is true if the user pressed the close button (restore UI).
|
||||
func pipDidStop(restored: Bool)
|
||||
/// Forward play/pause commands from PiP overlay to mpv
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
/// Query whether mpv is currently playing
|
||||
var isPipPlaying: Bool { get }
|
||||
}
|
||||
|
||||
/// Encapsulates macOS Picture-in-Picture using the private PIP.framework (PIPViewController).
|
||||
@@ -17,134 +17,134 @@ protocol MpvPipDelegate: AnyObject {
|
||||
/// mpv continues rendering to its CAMetalLayer throughout PiP.
|
||||
class MpvPipController: NSObject, PIPViewControllerDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: - Properties
|
||||
|
||||
private lazy var pip: PIPViewController = {
|
||||
let vc = PIPViewController()
|
||||
vc.delegate = self
|
||||
return vc
|
||||
}()
|
||||
private lazy var pip: PIPViewController = {
|
||||
let vc = PIPViewController()
|
||||
vc.delegate = self
|
||||
return vc
|
||||
}()
|
||||
|
||||
private var pipVideoVC: NSViewController?
|
||||
private var pipVideoView: NSView?
|
||||
private var pipVideoVC: NSViewController?
|
||||
private var pipVideoView: NSView?
|
||||
|
||||
weak var delegate: MpvPipDelegate?
|
||||
private(set) var isActive = false
|
||||
var autoPipEnabled = false
|
||||
weak var delegate: MpvPipDelegate?
|
||||
private(set) var isActive = false
|
||||
var autoPipEnabled = false
|
||||
|
||||
// Keep reference to the window for restore animation
|
||||
private weak var sourceWindow: NSWindow?
|
||||
// Keep reference to the window for restore animation
|
||||
private weak var sourceWindow: NSWindow?
|
||||
|
||||
// MARK: - Public API
|
||||
// MARK: - Public API
|
||||
|
||||
static var isSupported: Bool { true }
|
||||
static var isSupported: Bool { true }
|
||||
|
||||
/// 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(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
|
||||
guard !isActive else { return }
|
||||
/// 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(metalLayer: CAMetalLayer, window: NSWindow, aspectRatio: NSSize) {
|
||||
guard !isActive else { return }
|
||||
|
||||
sourceWindow = window
|
||||
sourceWindow = window
|
||||
|
||||
// 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 = metalLayer
|
||||
// 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 = metalLayer
|
||||
|
||||
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
|
||||
// Without this, the explicit drawableSize set by updateFrame() (main window size)
|
||||
// persists and causes mpv/MoltenVK to render at the wrong resolution in PiP.
|
||||
metalLayer.drawableSize = .zero
|
||||
// Reset drawableSize to zero so it auto-derives from the layer's bounds.
|
||||
// Without this, the explicit drawableSize set by updateFrame() (main window size)
|
||||
// persists and causes mpv/MoltenVK to render at the wrong resolution in PiP.
|
||||
metalLayer.drawableSize = .zero
|
||||
|
||||
// Create a view controller for PIPViewController
|
||||
let vc = NSViewController()
|
||||
vc.view = videoView
|
||||
// Create a view controller for PIPViewController
|
||||
let vc = NSViewController()
|
||||
vc.view = videoView
|
||||
|
||||
pipVideoVC = vc
|
||||
pipVideoView = videoView
|
||||
pipVideoVC = vc
|
||||
pipVideoView = videoView
|
||||
|
||||
// Configure PiP
|
||||
pip.playing = delegate?.isPipPlaying ?? false
|
||||
pip.aspectRatio = aspectRatio
|
||||
pip.replacementWindow = window
|
||||
pip.replacementRect = window.contentView?.frame ?? .zero
|
||||
// Configure PiP
|
||||
pip.playing = delegate?.isPipPlaying ?? false
|
||||
pip.aspectRatio = aspectRatio
|
||||
pip.replacementWindow = window
|
||||
pip.replacementRect = window.contentView?.frame ?? .zero
|
||||
|
||||
delegate?.pipWillStart()
|
||||
delegate?.pipWillStart()
|
||||
|
||||
// Present PiP
|
||||
pip.presentAsPicture(inPicture: vc)
|
||||
isActive = true
|
||||
delegate?.pipDidStart()
|
||||
}
|
||||
// Present PiP
|
||||
pip.presentAsPicture(inPicture: vc)
|
||||
isActive = true
|
||||
delegate?.pipDidStart()
|
||||
}
|
||||
|
||||
func stopPip() {
|
||||
guard isActive else { return }
|
||||
pip.dismiss(pipVideoVC!)
|
||||
}
|
||||
func stopPip() {
|
||||
guard isActive else { return }
|
||||
pip.dismiss(pipVideoVC!)
|
||||
}
|
||||
|
||||
/// Update the play/pause button state in the PiP overlay
|
||||
func setPlaying(_ playing: Bool) {
|
||||
pip.playing = playing
|
||||
}
|
||||
/// Update the play/pause button state in the PiP overlay
|
||||
func setPlaying(_ playing: Bool) {
|
||||
pip.playing = playing
|
||||
}
|
||||
|
||||
/// Update the aspect ratio (e.g., when video track changes)
|
||||
func setAspectRatio(_ size: NSSize) {
|
||||
pip.aspectRatio = size
|
||||
}
|
||||
/// Update the aspect ratio (e.g., when video track changes)
|
||||
func setAspectRatio(_ size: NSSize) {
|
||||
pip.aspectRatio = size
|
||||
}
|
||||
|
||||
func setAutoStart(_ enabled: Bool) {
|
||||
autoPipEnabled = enabled
|
||||
}
|
||||
func setAutoStart(_ enabled: Bool) {
|
||||
autoPipEnabled = enabled
|
||||
}
|
||||
|
||||
/// 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 Metal layer that was hosted in PiP.
|
||||
@discardableResult
|
||||
func detachLayer() -> CAMetalLayer? {
|
||||
let metalLayer = pipVideoView?.layer as? CAMetalLayer
|
||||
pipVideoView?.layer = CALayer() // detach before removing
|
||||
pipVideoView = nil
|
||||
pipVideoVC = nil
|
||||
return metalLayer
|
||||
}
|
||||
/// 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 Metal layer that was hosted in PiP.
|
||||
@discardableResult
|
||||
func detachLayer() -> CAMetalLayer? {
|
||||
let metalLayer = pipVideoView?.layer as? CAMetalLayer
|
||||
pipVideoView?.layer = CALayer() // detach before removing
|
||||
pipVideoView = nil
|
||||
pipVideoVC = nil
|
||||
return metalLayer
|
||||
}
|
||||
|
||||
// MARK: - PIPViewControllerDelegate
|
||||
// MARK: - PIPViewControllerDelegate
|
||||
|
||||
func pipShouldClose(_ pip: PIPViewController) -> Bool {
|
||||
prepareForClose()
|
||||
return true
|
||||
}
|
||||
func pipShouldClose(_ pip: PIPViewController) -> Bool {
|
||||
prepareForClose()
|
||||
return true
|
||||
}
|
||||
|
||||
func pipWillClose(_ pip: PIPViewController) {
|
||||
prepareForClose()
|
||||
}
|
||||
func pipWillClose(_ pip: PIPViewController) {
|
||||
prepareForClose()
|
||||
}
|
||||
|
||||
func pipDidClose(_ pip: PIPViewController) {
|
||||
isActive = false
|
||||
delegate?.pipDidStop(restored: true)
|
||||
}
|
||||
func pipDidClose(_ pip: PIPViewController) {
|
||||
isActive = false
|
||||
delegate?.pipDidStop(restored: true)
|
||||
}
|
||||
|
||||
func pipActionPlay(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(true)
|
||||
}
|
||||
func pipActionPlay(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(true)
|
||||
}
|
||||
|
||||
func pipActionPause(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(false)
|
||||
}
|
||||
func pipActionPause(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(false)
|
||||
}
|
||||
|
||||
func pipActionStop(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(false)
|
||||
}
|
||||
func pipActionStop(_ pip: PIPViewController) {
|
||||
delegate?.pipSetPlaying(false)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
// MARK: - Private
|
||||
|
||||
private func prepareForClose() {
|
||||
guard let window = sourceWindow else { return }
|
||||
pip.replacementWindow = window
|
||||
pip.replacementRect = window.contentView?.frame ?? .zero
|
||||
// Bring the main window forward for the restore animation
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
private func prepareForClose() {
|
||||
guard let window = sourceWindow else { return }
|
||||
pip.replacementWindow = window
|
||||
pip.replacementRect = window.contentView?.frame ?? .zero
|
||||
// Bring the main window forward for the restore animation
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,256 +4,256 @@ import Libmpv
|
||||
/// Core MPV player using Metal rendering.
|
||||
class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
|
||||
private weak var window: NSWindow?
|
||||
private var playbackActivity: NSObjectProtocol?
|
||||
private var layerHiddenForOcclusion = false
|
||||
private weak var window: NSWindow?
|
||||
private var playbackActivity: NSObjectProtocol?
|
||||
private var layerHiddenForOcclusion = false
|
||||
|
||||
func initialize(in window: NSWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
func initialize(in window: NSWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
print("[MpvPlayerCore] No content view")
|
||||
return false
|
||||
}
|
||||
guard let contentView = window.contentView else {
|
||||
print("[MpvPlayerCore] No content view")
|
||||
return false
|
||||
}
|
||||
|
||||
self.window = window
|
||||
self.window = window
|
||||
|
||||
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]
|
||||
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]
|
||||
|
||||
metalLayer = layer
|
||||
metalLayer = layer
|
||||
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.addSublayer(layer)
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.addSublayer(layer)
|
||||
|
||||
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
|
||||
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
|
||||
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
return false
|
||||
}
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
return false
|
||||
}
|
||||
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillEnterFullScreen),
|
||||
name: NSWindow.willEnterFullScreenNotification,
|
||||
object: window
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillEnterFullScreen),
|
||||
name: NSWindow.willEnterFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidEnterFullScreen),
|
||||
name: NSWindow.didEnterFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillExitFullScreen),
|
||||
name: NSWindow.willExitFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidExitFullScreen),
|
||||
name: NSWindow.didExitFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowOcclusionDidChange),
|
||||
name: NSWindow.didChangeOcclusionStateNotification,
|
||||
object: window
|
||||
)
|
||||
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
}
|
||||
|
||||
override func configurePlatformMpvOptions() {
|
||||
guard let mpv else { return }
|
||||
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio"))
|
||||
// Default fifo (vsync) mode — mailbox was causing continuous GPU rendering even when paused
|
||||
}
|
||||
|
||||
var videoLayer: CAMetalLayer? { metalLayer }
|
||||
|
||||
func reattachMetalLayer() {
|
||||
guard let metalLayer, let contentView = window?.contentView else { return }
|
||||
|
||||
if metalLayer.superlayer == nil {
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.insertSublayer(metalLayer, at: 0)
|
||||
metalLayer.frame = contentView.bounds
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
metalLayer.contentsScale = screen.backingScaleFactor
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: contentView.bounds.width * screen.backingScaleFactor,
|
||||
height: contentView.bounds.height * screen.backingScaleFactor
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidEnterFullScreen),
|
||||
name: NSWindow.didEnterFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowWillExitFullScreen),
|
||||
name: NSWindow.willExitFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidExitFullScreen),
|
||||
name: NSWindow.didExitFullScreenNotification,
|
||||
object: window
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowOcclusionDidChange),
|
||||
name: NSWindow.didChangeOcclusionStateNotification,
|
||||
object: window
|
||||
)
|
||||
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func configurePlatformMpvOptions() {
|
||||
guard let mpv else { return }
|
||||
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio"))
|
||||
// Default fifo (vsync) mode — mailbox was causing continuous GPU rendering even when paused
|
||||
print("[MpvPlayerCore] Metal layer reattached to window")
|
||||
}
|
||||
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
}
|
||||
|
||||
private var isVisible = false
|
||||
private var pausedState = true
|
||||
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard let metalLayer, !isPipActive else { return }
|
||||
|
||||
isVisible = visible
|
||||
isBackgrounded = !visible
|
||||
|
||||
if visible {
|
||||
metalLayer.removeFromSuperlayer()
|
||||
if let superlayer = window?.contentView?.layer {
|
||||
superlayer.insertSublayer(metalLayer, at: 0)
|
||||
}
|
||||
beginPlaybackActivity()
|
||||
} else {
|
||||
endPlaybackActivity()
|
||||
}
|
||||
|
||||
var videoLayer: CAMetalLayer? { metalLayer }
|
||||
metalLayer.isHidden = !visible
|
||||
print("[MpvPlayerCore] setVisible(\(visible))")
|
||||
}
|
||||
|
||||
func reattachMetalLayer() {
|
||||
guard let metalLayer, let contentView = window?.contentView else { return }
|
||||
func setPaused(_ paused: Bool) {
|
||||
pausedState = paused
|
||||
if paused {
|
||||
endPlaybackActivity()
|
||||
} else if isVisible {
|
||||
beginPlaybackActivity()
|
||||
}
|
||||
}
|
||||
|
||||
if metalLayer.superlayer == nil {
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.insertSublayer(metalLayer, at: 0)
|
||||
metalLayer.frame = contentView.bounds
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
metalLayer.contentsScale = screen.backingScaleFactor
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: contentView.bounds.width * screen.backingScaleFactor,
|
||||
height: contentView.bounds.height * screen.backingScaleFactor
|
||||
)
|
||||
}
|
||||
}
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let metalLayer, !isPipActive else { return }
|
||||
|
||||
print("[MpvPlayerCore] Metal layer reattached to window")
|
||||
if let frame {
|
||||
metalLayer.frame = frame
|
||||
} else if let contentView = window?.contentView {
|
||||
metalLayer.frame = contentView.bounds
|
||||
}
|
||||
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: metalLayer.frame.width * scale,
|
||||
height: metalLayer.frame.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
private var isVisible = false
|
||||
private var pausedState = true
|
||||
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)")
|
||||
}
|
||||
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard let metalLayer, !isPipActive else { return }
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
guard let metalLayer else { return }
|
||||
|
||||
isVisible = visible
|
||||
isBackgrounded = !visible
|
||||
|
||||
if visible {
|
||||
metalLayer.removeFromSuperlayer()
|
||||
if let superlayer = window?.contentView?.layer {
|
||||
superlayer.insertSublayer(metalLayer, at: 0)
|
||||
}
|
||||
beginPlaybackActivity()
|
||||
} else {
|
||||
endPlaybackActivity()
|
||||
}
|
||||
|
||||
metalLayer.isHidden = !visible
|
||||
print("[MpvPlayerCore] setVisible(\(visible))")
|
||||
var edrHeadroom: CGFloat = 1.0
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
|
||||
}
|
||||
|
||||
func setPaused(_ paused: Bool) {
|
||||
pausedState = paused
|
||||
if paused {
|
||||
endPlaybackActivity()
|
||||
} else if isVisible {
|
||||
beginPlaybackActivity()
|
||||
}
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
|
||||
|
||||
print(
|
||||
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
|
||||
)
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
endPlaybackActivity()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
@objc private func windowWillEnterFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] willEnterFullScreen - disabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
}
|
||||
|
||||
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] didEnterFullScreen - re-enabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
}
|
||||
|
||||
@objc private func windowWillExitFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] willExitFullScreen - disabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
}
|
||||
|
||||
@objc private func windowDidExitFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] didExitFullScreen - re-enabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
}
|
||||
|
||||
@objc private func windowOcclusionDidChange(_ notification: Notification) {
|
||||
guard let metalLayer, mpv != nil, !isPipActive else { return }
|
||||
|
||||
let windowVisible = window?.occlusionState.contains(.visible) ?? true
|
||||
if !windowVisible && !layerHiddenForOcclusion {
|
||||
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
|
||||
metalLayer.isHidden = true
|
||||
layerHiddenForOcclusion = true
|
||||
isBackgrounded = true
|
||||
endPlaybackActivity()
|
||||
} else if windowVisible && layerHiddenForOcclusion {
|
||||
print("[MpvPlayerCore] Window visible - showing Metal layer")
|
||||
layerHiddenForOcclusion = false
|
||||
metalLayer.isHidden = false
|
||||
isBackgrounded = false
|
||||
if !pausedState {
|
||||
beginPlaybackActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let metalLayer, !isPipActive else { return }
|
||||
private func beginPlaybackActivity() {
|
||||
guard playbackActivity == nil else { return }
|
||||
playbackActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .latencyCritical],
|
||||
reason: "Video playback"
|
||||
)
|
||||
print("[MpvPlayerCore] Began playback activity assertion")
|
||||
}
|
||||
|
||||
if let frame {
|
||||
metalLayer.frame = frame
|
||||
} else if let contentView = window?.contentView {
|
||||
metalLayer.frame = contentView.bounds
|
||||
}
|
||||
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: metalLayer.frame.width * scale,
|
||||
height: metalLayer.frame.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)")
|
||||
}
|
||||
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
guard let metalLayer else { return }
|
||||
|
||||
var edrHeadroom: CGFloat = 1.0
|
||||
if let screen = window?.screen ?? NSScreen.main {
|
||||
edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
|
||||
}
|
||||
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
|
||||
|
||||
print(
|
||||
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
|
||||
)
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
endPlaybackActivity()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
@objc private func windowWillEnterFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] willEnterFullScreen - disabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
}
|
||||
|
||||
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] didEnterFullScreen - re-enabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
}
|
||||
|
||||
@objc private func windowWillExitFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] willExitFullScreen - disabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
}
|
||||
|
||||
@objc private func windowDidExitFullScreen(_ notification: Notification) {
|
||||
guard mpv != nil, !isPipActive else { return }
|
||||
print("[MpvPlayerCore] didExitFullScreen - re-enabling video output")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
}
|
||||
|
||||
@objc private func windowOcclusionDidChange(_ notification: Notification) {
|
||||
guard let metalLayer, mpv != nil, !isPipActive else { return }
|
||||
|
||||
let windowVisible = window?.occlusionState.contains(.visible) ?? true
|
||||
if !windowVisible && !layerHiddenForOcclusion {
|
||||
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
|
||||
metalLayer.isHidden = true
|
||||
layerHiddenForOcclusion = true
|
||||
isBackgrounded = true
|
||||
endPlaybackActivity()
|
||||
} else if windowVisible && layerHiddenForOcclusion {
|
||||
print("[MpvPlayerCore] Window visible - showing Metal layer")
|
||||
layerHiddenForOcclusion = false
|
||||
metalLayer.isHidden = false
|
||||
isBackgrounded = false
|
||||
if !pausedState {
|
||||
beginPlaybackActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginPlaybackActivity() {
|
||||
guard playbackActivity == nil else { return }
|
||||
playbackActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .latencyCritical],
|
||||
reason: "Video playback"
|
||||
)
|
||||
print("[MpvPlayerCore] Began playback activity assertion")
|
||||
}
|
||||
|
||||
private func endPlaybackActivity() {
|
||||
guard let playbackActivity else { return }
|
||||
ProcessInfo.processInfo.endActivity(playbackActivity)
|
||||
self.playbackActivity = nil
|
||||
print("[MpvPlayerCore] Ended playback activity assertion")
|
||||
}
|
||||
private func endPlaybackActivity() {
|
||||
guard let playbackActivity else { return }
|
||||
ProcessInfo.processInfo.endActivity(playbackActivity)
|
||||
self.playbackActivity = nil
|
||||
print("[MpvPlayerCore] Ended playback activity assertion")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,345 +4,371 @@ import FlutterMacOS
|
||||
/// Flutter plugin that bridges MPV player to Dart via method and event channels
|
||||
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var enteredPipViaAuto = false
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var enteredPipViaAuto = false
|
||||
|
||||
// MARK: - FlutterPlugin Registration
|
||||
// MARK: - FlutterPlugin Registration
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
// Method channel for commands
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
// Method channel for commands
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
|
||||
// Event channel for state updates
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
// Event channel for state updates
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
|
||||
print("[MpvPlayerPlugin] Registered with Flutter")
|
||||
print("[MpvPlayerPlugin] Registered with Flutter")
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
self.eventSink = events
|
||||
print("[MpvPlayerPlugin] Event stream connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
print("[MpvPlayerPlugin] Event stream disconnected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
// MARK: - PiP
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
self.eventSink = events
|
||||
print("[MpvPlayerPlugin] Event stream connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
print("[MpvPlayerPlugin] Event stream disconnected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP
|
||||
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
let controller = MpvPipController()
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
enterPip(manual: true, result: result)
|
||||
case "exit":
|
||||
pipController?.stopPip()
|
||||
result(nil)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
autoPipEnabled = ready
|
||||
let pip = ensurePipController()
|
||||
pip.setAutoStart(ready)
|
||||
if ready {
|
||||
// Observe app resigning active to auto-enter PiP
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appDidResignActive), name: NSApplication.didResignActiveNotification, object: nil)
|
||||
// Observe app becoming active to auto-exit PiP
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
} else {
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
}
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter PiP by moving the Metal rendering layer to a PiP window.
|
||||
/// No VO switching — mpv keeps rendering to the same Metal layer.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard let playerCore = playerCore else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
|
||||
return
|
||||
}
|
||||
guard let metalLayer = playerCore.videoLayer else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
|
||||
return
|
||||
}
|
||||
guard let window = findFlutterWindow()?.0 else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "No window"])
|
||||
return
|
||||
}
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
let controller = MpvPipController()
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
enterPip(manual: true, result: result)
|
||||
case "exit":
|
||||
pipController?.stopPip()
|
||||
result(nil)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
autoPipEnabled = ready
|
||||
let pip = ensurePipController()
|
||||
guard !pip.isActive else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"])
|
||||
return
|
||||
pip.setAutoStart(ready)
|
||||
if ready {
|
||||
// Observe app resigning active to auto-enter PiP
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(appDidResignActive),
|
||||
name: NSApplication.didResignActiveNotification, object: nil)
|
||||
// Observe app becoming active to auto-exit PiP
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(appDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
} else {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
}
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// Get video dimensions for aspect ratio
|
||||
var aspectRatio = NSSize(width: 16, height: 9) // default
|
||||
if let w = playerCore.getProperty("width"), let h = playerCore.getProperty("height"),
|
||||
let width = Double(w), let height = Double(h), width > 0 && height > 0 {
|
||||
aspectRatio = NSSize(width: width, height: height)
|
||||
}
|
||||
|
||||
enteredPipViaAuto = !manual
|
||||
playerCore.isPipActive = true
|
||||
|
||||
pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio)
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
result?(["success": true])
|
||||
/// Enter PiP by moving the Metal rendering layer to a PiP window.
|
||||
/// No VO switching — mpv keeps rendering to the same Metal layer.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard let playerCore = playerCore else {
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "Player not initialized",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard let metalLayer = playerCore.videoLayer else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "No video layer"])
|
||||
return
|
||||
}
|
||||
guard let window = findFlutterWindow()?.0 else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "No window"])
|
||||
return
|
||||
}
|
||||
|
||||
/// App resigned active — auto-enter PiP if enabled and playing
|
||||
@objc private func appDidResignActive() {
|
||||
guard autoPipEnabled,
|
||||
let pc = playerCore,
|
||||
!pc.isPipActive,
|
||||
!pc.isPaused,
|
||||
pipController?.autoPipEnabled == true else { return }
|
||||
print("[MpvPlayerPlugin] Auto-PiP: app resigned active, entering PiP")
|
||||
enterPip(manual: false)
|
||||
let pip = ensurePipController()
|
||||
guard !pip.isActive else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"])
|
||||
return
|
||||
}
|
||||
|
||||
/// App became active — auto-exit PiP if it was entered automatically
|
||||
@objc private func appDidBecomeActive() {
|
||||
guard enteredPipViaAuto, let pip = pipController, pip.isActive else { return }
|
||||
print("[MpvPlayerPlugin] Auto-PiP: app became active, exiting PiP")
|
||||
// Get video dimensions for aspect ratio
|
||||
var aspectRatio = NSSize(width: 16, height: 9) // default
|
||||
if let w = playerCore.getProperty("width"), let h = playerCore.getProperty("height"),
|
||||
let width = Double(w), let height = Double(h), width > 0 && height > 0
|
||||
{
|
||||
aspectRatio = NSSize(width: width, height: height)
|
||||
}
|
||||
|
||||
enteredPipViaAuto = !manual
|
||||
playerCore.isPipActive = true
|
||||
|
||||
pip.startPip(metalLayer: metalLayer, window: window, aspectRatio: aspectRatio)
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
result?(["success": true])
|
||||
}
|
||||
|
||||
/// App resigned active — auto-enter PiP if enabled and playing
|
||||
@objc private func appDidResignActive() {
|
||||
guard autoPipEnabled,
|
||||
let pc = playerCore,
|
||||
!pc.isPipActive,
|
||||
!pc.isPaused,
|
||||
pipController?.autoPipEnabled == true
|
||||
else { return }
|
||||
print("[MpvPlayerPlugin] Auto-PiP: app resigned active, entering PiP")
|
||||
enterPip(manual: false)
|
||||
}
|
||||
|
||||
/// App became active — auto-exit PiP if it was entered automatically
|
||||
@objc private func appDidBecomeActive() {
|
||||
guard enteredPipViaAuto, let pip = pipController, pip.isActive else { return }
|
||||
print("[MpvPlayerPlugin] Auto-PiP: app became active, exiting PiP")
|
||||
pip.stopPip()
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if self.playerCore?.isInitialized == true {
|
||||
print("[MpvPlayerPlugin] Already initialized")
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the Flutter window
|
||||
guard let (window, _, _) = self.findFlutterWindow() else {
|
||||
print("[MpvPlayerPlugin] Failed to find Flutter window")
|
||||
result(
|
||||
FlutterError(
|
||||
code: "NO_WINDOW", message: "Could not find Flutter window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Create and initialize player core
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
print("[MpvPlayerPlugin] Failed to initialize MPV")
|
||||
result(
|
||||
FlutterError(
|
||||
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
|
||||
// Start hidden
|
||||
core.setVisible(false)
|
||||
|
||||
print("[MpvPlayerPlugin] Initialized successfully")
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
if let pip = self.pipController, pip.isActive {
|
||||
pip.stopPip()
|
||||
pip.detachLayer()
|
||||
}
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
print("[MpvPlayerPlugin] Disposed")
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
playerCore?.setProperty(name, value: value)
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if self.playerCore?.isInitialized == true {
|
||||
print("[MpvPlayerPlugin] Already initialized")
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the Flutter window
|
||||
guard let (window, _, _) = self.findFlutterWindow() else {
|
||||
print("[MpvPlayerPlugin] Failed to find Flutter window")
|
||||
result(FlutterError(code: "NO_WINDOW", message: "Could not find Flutter window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Create and initialize player core
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
print("[MpvPlayerPlugin] Failed to initialize MPV")
|
||||
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
|
||||
// Start hidden
|
||||
core.setVisible(false)
|
||||
|
||||
print("[MpvPlayerPlugin] Initialized successfully")
|
||||
result(true)
|
||||
}
|
||||
if name == "pause" {
|
||||
let isPlaying = value == "no"
|
||||
pipController?.setPlaying(isPlaying)
|
||||
playerCore?.setPaused(!isPlaying)
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
if let pip = self.pipController, pip.isActive {
|
||||
pip.stopPip()
|
||||
pip.detachLayer()
|
||||
}
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
print("[MpvPlayerPlugin] Disposed")
|
||||
result(nil)
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func findFlutterWindow() -> (NSWindow, NSView, NSView)? {
|
||||
for window in NSApplication.shared.windows {
|
||||
if window is MainFlutterWindow,
|
||||
let contentView = window.contentView,
|
||||
let contentVC = window.contentViewController
|
||||
{
|
||||
let flutterView = contentVC.view
|
||||
return (window, contentView, flutterView)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setProperty(name, value: value)
|
||||
|
||||
if name == "pause" {
|
||||
let isPlaying = value == "no"
|
||||
pipController?.setPlaying(isPlaying)
|
||||
playerCore?.setPaused(!isPlaying)
|
||||
}
|
||||
|
||||
result(nil)
|
||||
// Fallback
|
||||
for window in NSApplication.shared.windows {
|
||||
if let contentView = window.contentView,
|
||||
let contentVC = window.contentViewController
|
||||
{
|
||||
let flutterView = contentVC.view
|
||||
return (window, contentView, flutterView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func findFlutterWindow() -> (NSWindow, NSView, NSView)? {
|
||||
for window in NSApplication.shared.windows {
|
||||
if window is MainFlutterWindow,
|
||||
let contentView = window.contentView,
|
||||
let contentVC = window.contentViewController {
|
||||
let flutterView = contentVC.view
|
||||
return (window, contentView, flutterView)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
for window in NSApplication.shared.windows {
|
||||
if let contentView = window.contentView,
|
||||
let contentVC = window.contentViewController {
|
||||
let flutterView = contentVC.view
|
||||
return (window, contentView, flutterView)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MpvPipDelegate
|
||||
|
||||
extension MpvPlayerPlugin: MpvPipDelegate {
|
||||
|
||||
func pipWillStart() {
|
||||
print("[MpvPlayerPlugin] PiP will start")
|
||||
func pipWillStart() {
|
||||
print("[MpvPlayerPlugin] PiP will start")
|
||||
}
|
||||
|
||||
func pipDidStart() {
|
||||
print("[MpvPlayerPlugin] PiP did start")
|
||||
}
|
||||
|
||||
func pipDidStop(restored: Bool) {
|
||||
print("[MpvPlayerPlugin] PiP did stop (restored: \(restored))")
|
||||
playerCore?.isPipActive = false
|
||||
enteredPipViaAuto = false
|
||||
|
||||
// Detach the Metal layer from the PiP wrapper view
|
||||
pipController?.detachLayer()
|
||||
|
||||
// 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 {
|
||||
playerCore?.forceDraw()
|
||||
}
|
||||
|
||||
func pipDidStart() {
|
||||
print("[MpvPlayerPlugin] PiP did start")
|
||||
}
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: false)
|
||||
}
|
||||
|
||||
func pipDidStop(restored: Bool) {
|
||||
print("[MpvPlayerPlugin] PiP did stop (restored: \(restored))")
|
||||
playerCore?.isPipActive = false
|
||||
enteredPipViaAuto = false
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
|
||||
pipController?.setPlaying(playing)
|
||||
}
|
||||
|
||||
// Detach the Metal layer from the PiP wrapper view
|
||||
pipController?.detachLayer()
|
||||
|
||||
// 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 {
|
||||
playerCore?.forceDraw()
|
||||
}
|
||||
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: false)
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
|
||||
pipController?.setPlaying(playing)
|
||||
}
|
||||
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user