584 lines
18 KiB
Swift
584 lines
18 KiB
Swift
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] = [:]
|
|
|
|
var coreBase: MpvPlayerCoreBase? { playerCore }
|
|
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
|
func updatePlayerFrame() { playerCore?.updateFrame() }
|
|
|
|
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)
|
|
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",
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|