chore: add native formatting checks

This commit is contained in:
edde746
2026-05-01 05:56:49 +02:00
parent 9bd5732f2b
commit 024af35bf5
69 changed files with 8848 additions and 8633 deletions
+109 -109
View File
@@ -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)
}
}
+224 -224
View File
@@ -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")
}
}
+326 -300
View File
@@ -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) }
}