Files
plezy/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

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