Files
plezy/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift
T
edde746 3d30ea1342 fix(macos): reduce idle CPU when video player is paused
- Remove vulkan-swap-mode mailbox (continuous GPU rendering even when paused)
- End playback activity assertion on pause, restore on play
- Fix PerformanceStatsService persistent frame callback leak
2026-03-12 23:22:58 +01:00

349 lines
12 KiB
Swift

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] = [:]
// 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
// MARK: - FlutterPlugin Registration
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
)
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)
}
}
// 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
}
let pip = ensurePipController()
guard !pip.isActive else {
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP already active"])
return
}
// 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
}
playerCore?.setProperty(name, value: value)
if name == "pause" {
let isPlaying = value == "no"
pipController?.setPlaying(isPlaying)
playerCore?.setPaused(!isPlaying)
}
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)
}
}
// 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
}
}
// 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
// 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) }
}