Files
plezy/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
T
edde746 a0269c6feb fix(player): keep HDR/Dolby Vision through zoom on iOS and tvOS
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.
2026-08-10 14:17:40 +02:00

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 }
}