Nonzero mpv video-zoom flips vo_avfoundation into a per-frame Core Image re-render that destroys HDR/DV passthrough - DV frames render near-black on tvOS (verified on Apple TV 4K, DV P7->8.1 content: panel luma mean 0.0 zoomed vs 87-103 unzoomed at locked exposure). Zoom now scales the AVSampleBufferDisplayLayer itself (a sublayerTransform on the container is ignored by the video plane) via the existing Player.setVideoZoom seam, and VideoFilterManager pins the mpv property to 0 on backends with native zoom. The layer tree at 100% stays identical to before: clipping engages only while zoomed, and updateFrame sizes the layer via bounds/position, which frame= decomposes to anyway. macOS keeps the property path (gpu-next zooms losslessly in-shader); Android is untouched.
588 lines
18 KiB
Swift
588 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] = [:]
|
|
|
|
// 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 }
|
|
}
|