import UIKit import Flutter import AVKit /// 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, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) } func updatePlayerFrame() { playerCore?.updateFrame() } // PiP private var pipController: MpvPipController? private var pipChannel: FlutterMethodChannel? private var autoPipEnabled = false private var isManualPipRequest = false private var pipTimebaseSyncTimer: Timer? private var pendingInlineRestoreAfterPip = false private var sceneActivationObserverRegistered = 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 { [weak instance] call, result in guard let instance else { result(nil) return } instance.handlePipCall(call, result: result) } } // MARK: - FlutterStreamHandler func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { self.eventSink = events return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { self.eventSink = nil return nil } // MARK: - FlutterPlugin Method Handler func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "initialize": handleInitialize(result: result) case "dispose": handleDispose(call: call, 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 "setDisplayCriteria": handleSetDisplayCriteria(call: call, result: result) case "setVisible": handleSetVisible(call: call, result: result) case "setVideoZoom": handleSetVideoZoom(call: call, result: result) case "isInitialized": result(playerCore?.isInitialized ?? false) case "updateFrame": handleUpdateFrame(result: result) case "setLogLevel": handleSetLogLevel(call: call, result: result) case "getAudioRenderingMode": result(Self.audioRenderingMode()) default: result(FlutterMethodNotImplemented) } } /// The system's resolved audio rendering mode, for the Dolby-prescribed /// playback badge. `AVAudioSession.renderingMode` is tvOS/iOS 17.2+ and is /// documented as populated for CarPlay and AirPlay routes, so an HDMI route /// is expected to report `notApplicable`; callers must treat that as /// "unknown", never as "not Dolby". private static func audioRenderingMode() -> [String: Any] { var out: [String: Any] = [:] let session = AVAudioSession.sharedInstance() out["maxOutputChannels"] = session.maximumOutputNumberOfChannels out["outputChannels"] = session.outputNumberOfChannels out["route"] = session.currentRoute.outputs.first?.portType.rawValue ?? "none" if #available(tvOS 17.2, iOS 17.2, *) { let mode = session.renderingMode out["rawValue"] = mode.rawValue out["name"] = switch mode { case .notApplicable: "notApplicable" case .monoStereo: "monoStereo" case .surround: "surround" case .spatialAudio: "spatialAudio" case .dolbyAudio: "dolbyAudio" case .dolbyAtmos: "dolbyAtmos" @unknown default: "unknown" } } else { out["rawValue"] = 0 out["name"] = "unavailable" } return out } // MARK: - PiP private func ensurePipController() -> MpvPipController? { if let existing = pipController { return existing } guard let layer = playerCore?.sampleBufferDisplayLayer else { return nil } let controller = MpvPipController(sampleBufferDisplayLayer: layer) controller.delegate = self pipController = controller return controller } private func registerSceneActivationObserver() { guard !sceneActivationObserverRegistered else { return } NotificationCenter.default.addObserver( self, selector: #selector(sceneDidActivate), name: UIScene.didActivateNotification, object: nil ) sceneActivationObserverRegistered = true } private func unregisterSceneActivationObserver() { guard sceneActivationObserverRegistered else { return } NotificationCenter.default.removeObserver( self, name: UIScene.didActivateNotification, object: nil) sceneActivationObserverRegistered = false } private var isSceneActive: Bool { #if os(iOS) ExternalDisplayManager.hasActiveApplicationScene #else UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive } #endif } private func restoreInlinePlayerAfterPip() { guard pendingInlineRestoreAfterPip, let playerCore = playerCore, !playerCore.isPipActive, !playerCore.isPipStarting else { return } print("[MpvPlayerPlugin] Restoring inline player after PiP") playerCore.setVisible(true) playerCore.updateFrame() if playerCore.isPaused { playerCore.forceDraw() } pendingInlineRestoreAfterPip = false } private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { DispatchQueue.main.async { [weak self] in guard let self = self else { result(nil); return } switch call.method { case "isSupported": result(MpvPipController.isSupported) case "enter": self.enterPip(manual: true, result: result) case "setAutoPipReady": if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool { self.autoPipEnabled = ready if ready { guard let pip = self.ensurePipController() else { result(nil) return } pip.setAutoStart(true) // Warm the layer so the system considers PiP possible if let pc = self.playerCore { pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) } } else { self.pipController?.setAutoStart(false) } } result(nil) default: result(FlutterMethodNotImplemented) } } } /// Prepare the shared AVFoundation display layer for PiP display. /// Returns the MpvPipController on success, nil on failure. @discardableResult private func preparePip() -> MpvPipController? { guard let playerCore = playerCore else { return nil } guard let pip = ensurePipController() else { return nil } pendingInlineRestoreAfterPip = false playerCore.setPipSubtitleCompositing(true) playerCore.isPipStarting = true pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused) pip.invalidatePlaybackState() return pip } /// Manual PiP entry (button press). Auto-PiP is handled by the system via /// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate. private func enterPip(manual: Bool, result: FlutterResult? = nil) { guard MpvPipController.isSupported else { result?([ "success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+", ]) return } guard playerCore != nil else { result?([ "success": false, "errorCode": "failed", "errorMessage": "Player not initialized", ]) return } if manual && isManualPipRequest { result?([ "success": false, "errorCode": "failed", "errorMessage": "A PiP start request is already pending", ]) return } guard let pip = preparePip() else { result?([ "success": false, "errorCode": "pip_prepare_failed", "errorMessage": "Failed to prepare PiP", ]) return } isManualPipRequest = manual pip.startPip(waitForFrame: manual) { [weak self] started in guard let self else { result?([ "success": false, "errorCode": "failed", "errorMessage": "Player disposed", ]) return } if started { result?(["success": true]) } else { if self.playerCore?.isPipStarting == true { self.cleanupPip(notify: false) } result?([ "success": false, "errorCode": "failed", "errorMessage": "PiP failed to start", ]) } } } /// Unified cleanup for all PiP exit paths private func cleanupPip(notify: Bool, pause: Bool = false) { playerCore?.setPipSubtitleCompositing(false) playerCore?.isPipStarting = false playerCore?.isPipActive = false isManualPipRequest = false stopPipTimebaseSync() if pause { playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in guard case .success = propertyResult else { return } self?.pipController?.invalidatePlaybackState() self?.syncPipTimebase() } } pendingInlineRestoreAfterPip = true if pendingInlineRestoreAfterPip { if isSceneActive { restoreInlinePlayerAfterPip() } else { print("[MpvPlayerPlugin] Deferring inline restore until scene activation") } } if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) } } /// Scene became active — restore inline playback if needed and re-warm the /// sample-buffer layer so future auto-PiP remains possible. @objc private func sceneDidActivate() { restoreInlinePlayerAfterPip() if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive { pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) } } // MARK: - Timebase Sync private func syncPipTimebase() { guard let playerCore = playerCore, let pipController = pipController else { return } pipController.syncTimebase( currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused ) } private func startPipTimebaseSync() { stopPipTimebaseSync() pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in self?.syncPipTimebase() } } private func stopPipTimebaseSync() { pipTimebaseSyncTimer?.invalidate() pipTimebaseSyncTimer = nil } // 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 { self.registerSceneActivationObserver() result(true) return } guard let window = self.findKeyWindow() else { result( FlutterError( code: "NO_WINDOW", message: "Could not find key window", details: nil)) return } // A partially torn-down core must not survive a rapid route replacement. self.pipController?.teardown() self.pipController = nil self.pendingInlineRestoreAfterPip = false self.stopPipTimebaseSync() self.playerCore?.dispose() self.playerCore = nil let core = MpvPlayerCore() core.delegate = self guard core.initialize(in: window) else { result( FlutterError( code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) return } self.playerCore = core self.registerSceneActivationObserver() core.setVisible(false) result(true) } } private func handleDispose(call: FlutterMethodCall, result: @escaping FlutterResult) { let args = call.arguments as? [String: Any] let preserveDisplayMode = args?["preserveDisplayMode"] as? Bool ?? false DispatchQueue.main.async { [weak self] in guard let self = self else { result(nil); return } NSLog("[MpvPlayerPlugin] dispose preserveDisplayMode=%@", preserveDisplayMode.description) self.pipController?.teardown() self.pipController = nil self.autoPipEnabled = false self.pendingInlineRestoreAfterPip = false self.unregisterSceneActivationObserver() self.stopPipTimebaseSync() self.playerCore?.dispose(preserveDisplayCriteria: preserveDisplayMode) self.playerCore = nil result(nil) } } func didSetPauseProperty(value: String) { pipController?.invalidatePlaybackState() if playerCore?.isPipActive == true { syncPipTimebase() } } private func handleSetDisplayCriteria(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any] else { result(FlutterError(code: "INVALID_ARGS", message: "Missing arguments", details: nil)) return } guard let core = playerCore else { result(nil) return } let extraDelayMs = int64Value(args["extraDelayMs"]).map { Int(clamping: $0) } ?? 0 guard let raw = args["criteria"] as? [String: Any] else { DispatchQueue.main.async { core.setServerDisplayCriteriaForPlayback(nil, extraDelayMs: extraDelayMs) { result(nil) } } return } let criteria = ServerDisplayCriteria( doviProfile: int64Value(raw["doviProfile"]) ?? 0, doviLevel: int64Value(raw["doviLevel"]) ?? 0, doviCompatibilityId: int64Value(raw["doviCompatibilityId"]), fps: doubleValue(raw["fps"]) ?? 0, width: Int32(truncatingIfNeeded: int64Value(raw["width"]) ?? 0), height: Int32(truncatingIfNeeded: int64Value(raw["height"]) ?? 0), gamma: stringValue(raw["transfer"]), primaries: stringValue(raw["primaries"]), colorMatrix: stringValue(raw["matrix"]) ) DispatchQueue.main.async { core.setServerDisplayCriteriaForPlayback(criteria, extraDelayMs: extraDelayMs) { result(nil) } } } private func handleSetVideoZoom(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any], let scale = doubleValue(args["scale"]) else { result(FlutterError(code: "INVALID_ARGS", message: "setVideoZoom requires scale", details: nil)) return } playerCore?.setVideoZoom(scale) result(nil) } private func int64Value(_ value: Any?) -> Int64? { switch value { case let value as Int64: return value case let value as Int: return Int64(value) case let value as NSNumber: return value.int64Value case let value as String: return Int64(value) default: return nil } } private func doubleValue(_ value: Any?) -> Double? { switch value { case let value as Double: return value case let value as NSNumber: return value.doubleValue case let value as String: return Double(value) default: return nil } } private func stringValue(_ value: Any?) -> String? { guard let value else { return nil } let string = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines) return string.isEmpty ? nil : string } // MARK: - Helpers private func findKeyWindow() -> UIWindow? { #if os(iOS) return ExternalDisplayManager.mainApplicationWindow() #else let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } for scene in scenes { if let window = scene.windows.first(where: { $0.isKeyWindow }) { return window } } for scene in scenes { if let window = scene.windows.first(where: { !$0.isHidden }) { return window } } return scenes.first?.windows.first #endif } } // MARK: - MpvPipDelegate extension MpvPlayerPlugin: MpvPipDelegate { func pipWillStart() { // If PiP was system-initiated (not via our enterPip), prepare the shared layer now. guard let playerCore = playerCore, !playerCore.isPipStarting else { return } print("[MpvPlayerPlugin] System-initiated PiP detected, preparing shared layer") if preparePip() == nil { print("[MpvPlayerPlugin] PiP preparation failed for system-initiated PiP") pipController?.stopPip() } } func pipDidStart() { playerCore?.isPipStarting = false playerCore?.isPipActive = true pendingInlineRestoreAfterPip = false pipChannel?.invokeMethod("onPipChanged", arguments: true) syncPipTimebase() startPipTimebaseSync() if isManualPipRequest { isManualPipRequest = false UIControl().sendAction( #selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) } } func pipDidStop(restored: Bool) { cleanupPip(notify: true, pause: !restored) } func pipDidFailToStart(error: Error?) { cleanupPip(notify: true) } func pipSetPlaying(_ playing: Bool) { playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in guard case .success = propertyResult else { return } self?.pipController?.invalidatePlaybackState() self?.syncPipTimebase() } } func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { guard let playerCore = playerCore else { completion() return } let newTime = max(0, playerCore.timePos + seconds) playerCore.commandAsync(["seek", String(newTime), "absolute"]) { [weak self] _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self?.pipController?.invalidatePlaybackState() completion() } } } var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) } var pipDuration: Double { playerCore?.duration ?? 0 } }