import Cocoa import FlutterMacOS /// Flutter plugin that bridges MPV player to Dart via method and event channels class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared { // MARK: - Properties private var playerCore: MpvPlayerCore? var eventSink: FlutterEventSink? private weak var registrar: FlutterPluginRegistrar? var nameToId: [String: Int] = [:] var coreBase: MpvPlayerCoreBase? { playerCore } func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) { playerCore?.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible) } func updatePlayerFrame() { playerCore?.updateFrame() } private var pipController: MpvPipController? private var pipChannel: FlutterMethodChannel? private var autoPipEnabled = false private var enteredPipViaAuto = false // MARK: - FlutterPlugin Registration static func register(with registrar: FlutterPluginRegistrar) { let methodChannel = FlutterMethodChannel( name: "com.plezy/mpv_player", binaryMessenger: registrar.messenger ) let eventChannel = FlutterEventChannel( name: "com.plezy/mpv_player/events", binaryMessenger: registrar.messenger ) let pipChannel = FlutterMethodChannel( name: "com.plezy/pip", binaryMessenger: registrar.messenger ) let instance = MpvPlayerPlugin() instance.registrar = registrar instance.pipChannel = pipChannel registrar.addMethodCallDelegate(instance, channel: methodChannel) eventChannel.setStreamHandler(instance) pipChannel.setMethodCallHandler(instance.handlePipCall) 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) } } 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 { NotificationCenter.default.removeObserver( self, name: NSApplication.didResignActiveNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(appDidResignActive), name: NSApplication.didResignActiveNotification, object: nil) 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 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.metalLayer else { result?(["success": false, "errorCode": "failed", "errorMessage": "No Metal layer"]) return } guard let window = findFlutterWindow()?.0 else { result?(["success": false, "errorCode": "failed", "errorMessage": "No window"]) return } let pip = ensurePipController() guard !pip.isActive else { result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"]) return } var aspectRatio = NSSize(width: 16, height: 9) if let videoSize = playerCore.videoSize { aspectRatio = NSSize(width: videoSize.width, height: videoSize.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 } if self.playerCore?.isInitialized == true { print("[MpvPlayerPlugin] Already initialized") result(true) return } 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 } 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 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) } } func didSetPauseProperty(value: String) { let isPlaying = value == "no" pipController?.setPlaying(isPlaying) playerCore?.setPaused(!isPlaying) } // 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) } } 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 } } // MARK: - MpvPipDelegate extension MpvPlayerPlugin: MpvPipDelegate { 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 pipController?.detachLayer() playerCore?.reattachMetalLayer() if playerCore?.isPaused == true { playerCore?.forceDraw() } pipChannel?.invokeMethod("onPipChanged", arguments: false) } func pipSetPlaying(_ playing: Bool) { guard let playerCore else { return } playerCore.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in guard case .success = propertyResult else { return } self?.pipController?.setPlaying(playing) playerCore.setPaused(!playing) } } var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } }