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.
936 lines
30 KiB
Swift
936 lines
30 KiB
Swift
import AVFoundation
|
|
#if os(tvOS)
|
|
import AVKit
|
|
#endif
|
|
import QuartzCore
|
|
import UIKit
|
|
|
|
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
|
|
class MpvPlayerCore: MpvPlayerCoreBase {
|
|
|
|
private var containerView: UIView?
|
|
private weak var window: UIWindow?
|
|
private var mainBlankView: UIView?
|
|
private var isVisible = false
|
|
private static var activeDisplayCriteriaKey: String?
|
|
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
|
|
#if os(tvOS)
|
|
private var displayModeSwitchWaiter: DisplayModeSwitchWaiter?
|
|
private var displayModeSwitchWaiterGeneration = 0
|
|
#endif
|
|
|
|
var isPipStarting = false
|
|
|
|
private static func log(_ message: String) {
|
|
NSLog("[MpvPlayerCore] %@", message)
|
|
}
|
|
|
|
func initialize(in window: UIWindow) -> Bool {
|
|
guard !isInitialized else {
|
|
print("[MpvPlayerCore] Already initialized")
|
|
return true
|
|
}
|
|
|
|
self.window = window
|
|
|
|
let container = UIView(frame: window.bounds)
|
|
container.backgroundColor = .black
|
|
container.isUserInteractionEnabled = false
|
|
|
|
let layer = MpvVideoLayer()
|
|
layer.frame = container.bounds
|
|
layer.contentsScale = window.screen.nativeScale
|
|
layer.isOpaque = true
|
|
layer.backgroundColor = UIColor.black.cgColor
|
|
layer.videoGravity = .resizeAspect
|
|
|
|
container.layer.addSublayer(layer)
|
|
containerView = container
|
|
videoLayer = layer
|
|
|
|
window.insertSubview(container, at: 0)
|
|
|
|
guard setupMpv() else {
|
|
print("[MpvPlayerCore] Failed to setup MPV")
|
|
layer.removeFromSuperlayer()
|
|
container.removeFromSuperview()
|
|
videoLayer = nil
|
|
containerView = nil
|
|
return false
|
|
}
|
|
|
|
setupNotifications()
|
|
#if os(iOS)
|
|
ExternalDisplayManager.shared.attach(core: self)
|
|
#endif
|
|
|
|
isInitialized = true
|
|
print("[MpvPlayerCore] Initialized successfully with MPV")
|
|
return true
|
|
}
|
|
|
|
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
|
|
|
|
func setVisible(_ visible: Bool) {
|
|
guard containerView != nil else { return }
|
|
|
|
isVisible = visible
|
|
if visible { refreshExternalDisplayAttachment() }
|
|
setContainerHidden(!visible)
|
|
if !visible { mainBlankView?.isHidden = true }
|
|
}
|
|
|
|
func updateFrame(_ frame: CGRect? = nil) {
|
|
guard let videoLayer, let containerView else { return }
|
|
|
|
withoutLayerAnimations {
|
|
if let frame {
|
|
containerView.frame = frame
|
|
} else if let superview = containerView.superview {
|
|
containerView.frame = superview.bounds
|
|
} else if let window {
|
|
containerView.frame = window.bounds
|
|
}
|
|
fitVideoLayer(videoLayer, in: containerView)
|
|
|
|
mainBlankView?.frame = window?.bounds ?? .zero
|
|
|
|
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
|
|
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
|
|
videoLayer.contentsScale = scale
|
|
}
|
|
|
|
#if os(iOS)
|
|
updateEDRMode(sigPeak: lastSigPeak)
|
|
#endif
|
|
}
|
|
|
|
/// Size the video layer to the container via bounds/position, never `frame`:
|
|
/// `frame` is undefined while the custom-zoom transform is non-identity.
|
|
private func fitVideoLayer(_ layer: MpvVideoLayer, in container: UIView) {
|
|
layer.bounds = CGRect(origin: .zero, size: container.bounds.size)
|
|
layer.position = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
|
|
}
|
|
|
|
/// Apply custom viewer zoom by scaling the video layer about its center.
|
|
///
|
|
/// Zoom must never reach mpv's `video-zoom` on this VO: any nonzero zoom
|
|
/// flips vo_avfoundation into a Core Image re-render of every frame, which
|
|
/// destroys HDR/Dolby Vision passthrough (DV frames render near-black on
|
|
/// tvOS). A CALayer transform keeps the sample-buffer scanout path intact.
|
|
/// The transform must sit on the display layer itself — a
|
|
/// `sublayerTransform` on the container is ignored by the video plane.
|
|
/// The layer's bounds never change, so the VO's bounds KVO stays quiet, and
|
|
/// the inline OSD sibling layer keeps its own geometry. PiP is unaffected:
|
|
/// the Dart side resets zoom before entry, and the system presents the
|
|
/// layer's buffers, not its on-screen transform.
|
|
func setVideoZoom(_ scale: Double) {
|
|
let clamped = min(max(scale, 0.25), 4.0)
|
|
Self.log("setVideoZoom(\(scale)) -> \(clamped)")
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, let container = self.containerView, let videoLayer = self.videoLayer else {
|
|
return
|
|
}
|
|
let zoomed = abs(clamped - 1.0) >= 0.0005
|
|
self.withoutLayerAnimations {
|
|
// Clip only while zoomed: at 100% the layer tree stays identical to a
|
|
// build without custom zoom, so the unzoomed path cannot regress
|
|
// (e.g. hardware-plane promotion of the sample-buffer layer).
|
|
container.clipsToBounds = zoomed
|
|
videoLayer.transform =
|
|
zoomed
|
|
? CATransform3DMakeScale(CGFloat(clamped), CGFloat(clamped), 1)
|
|
: CATransform3DIdentity
|
|
}
|
|
}
|
|
}
|
|
|
|
func externalDisplayDidChange() {
|
|
refreshExternalDisplayAttachment()
|
|
}
|
|
|
|
private func refreshExternalDisplayAttachment() {
|
|
guard containerView != nil else { return }
|
|
|
|
let externalSuperview = externalVideoSuperview
|
|
|
|
if let externalSuperview {
|
|
moveContainerView(to: externalSuperview)
|
|
setMainBlankViewVisible(true)
|
|
} else if isVisible, let window {
|
|
moveContainerView(to: window)
|
|
setMainBlankViewVisible(false)
|
|
} else {
|
|
setMainBlankViewVisible(false)
|
|
}
|
|
|
|
setContainerHidden(!isVisible)
|
|
updateFrame()
|
|
}
|
|
|
|
private var externalVideoSuperview: UIView? {
|
|
#if os(iOS)
|
|
isVisible && !isPipActive && !isPipStarting
|
|
? ExternalDisplayManager.shared.videoSuperview
|
|
: nil
|
|
#else
|
|
nil
|
|
#endif
|
|
}
|
|
|
|
private func moveContainerView(to superview: UIView) {
|
|
guard let containerView else { return }
|
|
|
|
withoutLayerAnimations {
|
|
if containerView.superview !== superview {
|
|
containerView.removeFromSuperview()
|
|
superview.insertSubview(containerView, at: 0)
|
|
} else if superview.subviews.first !== containerView {
|
|
superview.insertSubview(containerView, at: 0)
|
|
}
|
|
|
|
containerView.frame = superview.bounds
|
|
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
}
|
|
}
|
|
|
|
private func setMainBlankViewVisible(_ visible: Bool) {
|
|
guard visible, let window else {
|
|
mainBlankView?.removeFromSuperview()
|
|
mainBlankView = nil
|
|
return
|
|
}
|
|
|
|
let blankView = mainBlankView ?? UIView(frame: window.bounds)
|
|
withoutLayerAnimations {
|
|
blankView.backgroundColor = .black
|
|
blankView.isUserInteractionEnabled = false
|
|
blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
blankView.frame = window.bounds
|
|
|
|
if blankView.superview !== window {
|
|
blankView.removeFromSuperview()
|
|
window.insertSubview(blankView, at: 0)
|
|
} else if window.subviews.first !== blankView {
|
|
window.insertSubview(blankView, at: 0)
|
|
}
|
|
|
|
blankView.isHidden = false
|
|
}
|
|
mainBlankView = blankView
|
|
}
|
|
|
|
private func setContainerHidden(_ hidden: Bool) {
|
|
withoutLayerAnimations {
|
|
containerView?.isHidden = hidden
|
|
}
|
|
}
|
|
|
|
private func withoutLayerAnimations(_ updates: () -> Void) {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
updates()
|
|
CATransaction.commit()
|
|
}
|
|
|
|
/// Nudge mpv to present the current paused frame after leaving PiP.
|
|
func forceDraw() {
|
|
command(["seek", "0", "relative+exact"])
|
|
}
|
|
|
|
private func restoreVideoPresentation() {
|
|
guard !isPipActive else { return }
|
|
|
|
refreshExternalDisplayAttachment()
|
|
recoverDisplayLayerIfNeeded()
|
|
updateEDRMode(sigPeak: lastSigPeak)
|
|
if isPaused { forceDraw() }
|
|
}
|
|
|
|
private func recoverDisplayLayerIfNeeded() {
|
|
guard let videoLayer else { return }
|
|
|
|
var requiresFlush = false
|
|
if #available(iOS 14.0, tvOS 14.0, *) {
|
|
requiresFlush = videoLayer.requiresFlushToResumeDecoding
|
|
}
|
|
let status = videoLayer.status
|
|
guard requiresFlush || status == .failed else { return }
|
|
|
|
videoLayer.flush()
|
|
}
|
|
|
|
override func updateEDRMode(sigPeak: Double) {
|
|
#if os(iOS)
|
|
guard let videoLayer else { return }
|
|
|
|
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0
|
|
if #available(iOS 26.0, *) {
|
|
withoutLayerAnimations {
|
|
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
|
|
}
|
|
} else if #available(iOS 17.0, *) {
|
|
withoutLayerAnimations {
|
|
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
@discardableResult
|
|
override func updateDisplayCriteria(
|
|
doviProfile: Int64,
|
|
doviLevel: Int64,
|
|
doviCompatibilityId: Int64?,
|
|
fps: Double,
|
|
width: Int32,
|
|
height: Int32,
|
|
sigPeak: Double,
|
|
gamma: String?,
|
|
primaries: String?,
|
|
colorMatrix: String?
|
|
) -> Bool {
|
|
#if os(tvOS)
|
|
lastDisplayCriteriaMutation = .skipped
|
|
guard let window = containerView?.window ?? self.window else { return false }
|
|
let displayManager = window.avDisplayManager
|
|
|
|
if !self.validateSideDataDimensions(width: Int64(width), height: Int64(height)) {
|
|
clearDisplayCriteria(displayManager, reason: "invalid video dimensions")
|
|
return false
|
|
}
|
|
|
|
let refreshRate = Float(fps > 0 ? fps : 0)
|
|
let sourceHasDolbyVision = doviProfile > 0
|
|
guard
|
|
refreshRate > 0 || sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil
|
|
else {
|
|
clearDisplayCriteria(displayManager, reason: "no display metadata")
|
|
return false
|
|
}
|
|
|
|
let sourceBaseRange = Self.resolveBaseDisplayDynamicRange(
|
|
sigPeak: sigPeak,
|
|
gamma: gamma,
|
|
primaries: primaries,
|
|
colorMatrix: colorMatrix,
|
|
doviCompatibilityId: doviCompatibilityId
|
|
)
|
|
let sourceRange: DisplayDynamicRange = sourceHasDolbyVision ? .dolbyVision : sourceBaseRange
|
|
// The HDR toggle (`hdrEnabled`) is authoritative on tvOS: when it's off,
|
|
// drive the HDMI link in SDR regardless of the source — Dolby Vision
|
|
// included — so turning HDR off actually leaves DV mode (issue #1262).
|
|
// This is the only path that gates the HDMI mode on tvOS;
|
|
// target-colorspace-hint is inert in the avfoundation VO and EDR is iOS.
|
|
var displayRange: DisplayDynamicRange
|
|
if !hdrEnabled {
|
|
displayRange = .sdr
|
|
} else if sourceHasDolbyVision {
|
|
displayRange = .dolbyVision
|
|
} else {
|
|
displayRange = Self.supportedDisplayDynamicRange(for: sourceBaseRange)
|
|
}
|
|
guard displayManager.isDisplayCriteriaMatchingEnabled else {
|
|
clearDisplayCriteria(displayManager, reason: "matching disabled")
|
|
return false
|
|
}
|
|
guard #available(tvOS 17.0, *) else {
|
|
clearDisplayCriteria(displayManager, reason: "display criteria unavailable")
|
|
return false
|
|
}
|
|
|
|
var formatDescription = Self.makeDisplayFormatDescription(
|
|
dynamicRange: displayRange,
|
|
width: width,
|
|
height: height,
|
|
doviProfile: doviProfile,
|
|
doviLevel: doviLevel,
|
|
doviCompatibilityId: doviCompatibilityId)
|
|
if formatDescription == nil, sourceHasDolbyVision, hdrEnabled {
|
|
displayRange = sourceBaseRange
|
|
formatDescription = Self.makeDisplayFormatDescription(
|
|
dynamicRange: displayRange,
|
|
width: width,
|
|
height: height,
|
|
doviProfile: doviProfile,
|
|
doviLevel: doviLevel,
|
|
doviCompatibilityId: doviCompatibilityId)
|
|
}
|
|
|
|
guard let formatDescription else {
|
|
clearDisplayCriteria(displayManager, reason: "format description failed")
|
|
return false
|
|
}
|
|
|
|
let criteriaKey =
|
|
"\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)"
|
|
if Self.activeDisplayCriteriaKey == criteriaKey && displayManager.preferredDisplayCriteria != nil {
|
|
lastDisplayCriteriaMutation = .unchanged
|
|
return true
|
|
}
|
|
|
|
let displayCriteria = AVDisplayCriteria(
|
|
refreshRate: refreshRate,
|
|
formatDescription: formatDescription
|
|
)
|
|
displayManager.preferredDisplayCriteria = displayCriteria
|
|
Self.activeDisplayCriteriaKey = criteriaKey
|
|
lastDisplayCriteriaMutation = .set
|
|
Self.log(
|
|
"preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
|
|
)
|
|
return true
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
func setServerDisplayCriteriaForPlayback(
|
|
_ criteria: ServerDisplayCriteria?,
|
|
extraDelayMs: Int,
|
|
completion: @escaping () -> Void
|
|
) {
|
|
let apply = { [weak self] in
|
|
guard let self else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
self.setServerDisplayCriteria(criteria) { [weak self] applied in
|
|
guard let self else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
#if os(tvOS)
|
|
guard applied || self.lastDisplayCriteriaMutation == .cleared else {
|
|
completion()
|
|
return
|
|
}
|
|
self.waitForDisplayModeSwitchIfNeeded(extraDelayMs: extraDelayMs, completion: completion)
|
|
#else
|
|
completion()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
if Thread.isMainThread {
|
|
apply()
|
|
} else {
|
|
DispatchQueue.main.async(execute: apply)
|
|
}
|
|
}
|
|
|
|
private enum DisplayCriteriaMutation {
|
|
case skipped
|
|
case unchanged
|
|
case set
|
|
case cleared
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private enum DisplayDynamicRange: String {
|
|
case sdr = "SDR"
|
|
case hdr10 = "HDR10"
|
|
case hlg = "HLG"
|
|
case dolbyVision = "Dolby Vision"
|
|
}
|
|
|
|
private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) {
|
|
if Self.activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
|
|
displayManager.preferredDisplayCriteria = nil
|
|
Self.activeDisplayCriteriaKey = nil
|
|
lastDisplayCriteriaMutation = .cleared
|
|
Self.log("preferredDisplayCriteria cleared (\(reason))")
|
|
} else {
|
|
lastDisplayCriteriaMutation = .unchanged
|
|
}
|
|
}
|
|
|
|
private func waitForDisplayModeSwitchIfNeeded(extraDelayMs: Int, completion: @escaping () -> Void) {
|
|
guard let window = containerView?.window ?? self.window else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
let displayManager = window.avDisplayManager
|
|
let mutation = lastDisplayCriteriaMutation
|
|
let shouldWaitForStart = mutation == .set || mutation == .cleared
|
|
if !shouldWaitForStart && !displayManager.isDisplayModeSwitchInProgress {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
displayModeSwitchWaiter?.cancel(complete: true)
|
|
displayModeSwitchWaiterGeneration += 1
|
|
let waiterGeneration = displayModeSwitchWaiterGeneration
|
|
let waiter = DisplayModeSwitchWaiter(
|
|
displayManager: displayManager,
|
|
shouldWaitForStart: shouldWaitForStart,
|
|
extraDelayMs: extraDelayMs
|
|
) { [weak self] in
|
|
if let self, self.displayModeSwitchWaiterGeneration == waiterGeneration {
|
|
self.displayModeSwitchWaiter = nil
|
|
}
|
|
completion()
|
|
}
|
|
displayModeSwitchWaiter = waiter
|
|
waiter.start()
|
|
}
|
|
|
|
private final class DisplayModeSwitchWaiter {
|
|
private weak var displayManager: AVDisplayManager?
|
|
private let shouldWaitForStart: Bool
|
|
private let extraDelayMs: Int
|
|
private let completion: () -> Void
|
|
private var startObserver: NSObjectProtocol?
|
|
private var endObserver: NSObjectProtocol?
|
|
private var startWatchdog: DispatchWorkItem?
|
|
private var endWatchdog: DispatchWorkItem?
|
|
private var settleWorkItem: DispatchWorkItem?
|
|
private var finished = false
|
|
private var completionDelivered = false
|
|
|
|
private static let startWindowMs = 500
|
|
private static let switchWatchdogMs = 8000
|
|
private static let settleMs = 200
|
|
|
|
init(
|
|
displayManager: AVDisplayManager,
|
|
shouldWaitForStart: Bool,
|
|
extraDelayMs: Int,
|
|
completion: @escaping () -> Void
|
|
) {
|
|
self.displayManager = displayManager
|
|
self.shouldWaitForStart = shouldWaitForStart
|
|
self.extraDelayMs = max(0, min(extraDelayMs, 10_000))
|
|
self.completion = completion
|
|
}
|
|
|
|
func start() {
|
|
guard let displayManager else {
|
|
finish(waited: false, reason: "manager unavailable")
|
|
return
|
|
}
|
|
|
|
if displayManager.isDisplayModeSwitchInProgress {
|
|
beginWaitingForEnd(reason: "already in progress")
|
|
return
|
|
}
|
|
|
|
guard shouldWaitForStart else {
|
|
finish(waited: false, reason: "no switch in progress")
|
|
return
|
|
}
|
|
|
|
let center = NotificationCenter.default
|
|
startObserver = center.addObserver(
|
|
forName: .AVDisplayManagerModeSwitchStart,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.beginWaitingForEnd(reason: "start notification")
|
|
}
|
|
|
|
let watchdog = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
if self.displayManager?.isDisplayModeSwitchInProgress == true {
|
|
self.beginWaitingForEnd(reason: "progress poll")
|
|
} else {
|
|
self.finish(waited: false, reason: "start watchdog")
|
|
}
|
|
}
|
|
startWatchdog = watchdog
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.startWindowMs), execute: watchdog)
|
|
}
|
|
|
|
func cancel(complete: Bool = false) {
|
|
finished = true
|
|
cleanup()
|
|
if complete { completeOnce() }
|
|
}
|
|
|
|
private func beginWaitingForEnd(reason: String) {
|
|
guard !finished else { return }
|
|
startWatchdog?.cancel()
|
|
startWatchdog = nil
|
|
if let startObserver {
|
|
NotificationCenter.default.removeObserver(startObserver)
|
|
self.startObserver = nil
|
|
}
|
|
|
|
guard displayManager?.isDisplayModeSwitchInProgress == true else {
|
|
finish(waited: true, reason: "ended before wait (\(reason))")
|
|
return
|
|
}
|
|
|
|
let center = NotificationCenter.default
|
|
endObserver = center.addObserver(
|
|
forName: .AVDisplayManagerModeSwitchEnd,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.finish(waited: true, reason: "end notification")
|
|
}
|
|
|
|
let watchdog = DispatchWorkItem { [weak self] in
|
|
self?.finish(waited: true, reason: "end watchdog")
|
|
}
|
|
endWatchdog = watchdog
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.switchWatchdogMs), execute: watchdog)
|
|
}
|
|
|
|
private func finish(waited: Bool, reason: String) {
|
|
guard !finished else { return }
|
|
finished = true
|
|
cleanup()
|
|
|
|
let delayMs = waited ? Self.settleMs + extraDelayMs : 0
|
|
MpvPlayerCore.log(
|
|
"display mode switch wait complete "
|
|
+ "(\(reason), waited: \(waited), extraDelayMs: \(extraDelayMs))"
|
|
)
|
|
guard delayMs > 0 else {
|
|
completeOnce()
|
|
return
|
|
}
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
self?.completeOnce()
|
|
}
|
|
settleWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
|
|
}
|
|
|
|
private func completeOnce() {
|
|
guard !completionDelivered else { return }
|
|
completionDelivered = true
|
|
settleWorkItem?.cancel()
|
|
settleWorkItem = nil
|
|
completion()
|
|
}
|
|
|
|
private func cleanup() {
|
|
startWatchdog?.cancel()
|
|
endWatchdog?.cancel()
|
|
settleWorkItem?.cancel()
|
|
startWatchdog = nil
|
|
endWatchdog = nil
|
|
settleWorkItem = nil
|
|
if let startObserver {
|
|
NotificationCenter.default.removeObserver(startObserver)
|
|
self.startObserver = nil
|
|
}
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
self.endObserver = nil
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
private static func resolveBaseDisplayDynamicRange(
|
|
sigPeak: Double,
|
|
gamma: String?,
|
|
primaries: String?,
|
|
colorMatrix: String?,
|
|
doviCompatibilityId: Int64?
|
|
) -> DisplayDynamicRange {
|
|
let normalizedGamma = normalizeColorTag(gamma)
|
|
let normalizedPrimaries = normalizeColorTag(primaries)
|
|
let normalizedColorMatrix = normalizeColorTag(colorMatrix)
|
|
|
|
if normalizedGamma.contains("hlg") || normalizedGamma.contains("arib") {
|
|
return .hlg
|
|
}
|
|
if normalizedGamma.contains("pq") || normalizedGamma.contains("smpte2084")
|
|
|| normalizedGamma.contains("st2084") || sigPeak > 1.0
|
|
|| normalizedPrimaries.contains("bt2020") || normalizedColorMatrix.contains("bt2020")
|
|
{
|
|
return .hdr10
|
|
}
|
|
switch doviCompatibilityId {
|
|
case 1, 6:
|
|
return .hdr10
|
|
case 4:
|
|
return .hlg
|
|
case 2:
|
|
return .sdr
|
|
default:
|
|
break
|
|
}
|
|
return .sdr
|
|
}
|
|
|
|
private static func normalizeColorTag(_ value: String?) -> String {
|
|
value?.lowercased().filter { $0.isLetter || $0.isNumber } ?? ""
|
|
}
|
|
|
|
private static func supportedDisplayDynamicRange(for range: DisplayDynamicRange) -> DisplayDynamicRange {
|
|
let availableModes = AVPlayer.availableHDRModes
|
|
switch range {
|
|
case .dolbyVision:
|
|
if availableModes.contains(.dolbyVision) { return .dolbyVision }
|
|
if availableModes.contains(.hdr10) { return .hdr10 }
|
|
if availableModes.contains(.hlg) { return .hlg }
|
|
return .sdr
|
|
case .hdr10:
|
|
return availableModes.contains(.hdr10) ? .hdr10 : .sdr
|
|
case .hlg:
|
|
return availableModes.contains(.hlg) ? .hlg : .sdr
|
|
case .sdr:
|
|
return .sdr
|
|
}
|
|
}
|
|
|
|
private static func makeDisplayFormatDescription(
|
|
dynamicRange: DisplayDynamicRange,
|
|
width: Int32,
|
|
height: Int32,
|
|
doviProfile: Int64,
|
|
doviLevel: Int64,
|
|
doviCompatibilityId: Int64?
|
|
) -> CMVideoFormatDescription? {
|
|
if dynamicRange == .dolbyVision {
|
|
// Profile 8.x always carries a compatibility id; profile 5 has none.
|
|
// We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8
|
|
// because mpv does not expose the compat id and that's by far the
|
|
// most common case.
|
|
let fallbackCompat: Int64 = doviProfile == 8 ? 1 : 0
|
|
let compat = UInt8(truncatingIfNeeded: doviCompatibilityId ?? fallbackCompat)
|
|
return makeDolbyVisionFormatDescription(
|
|
width: width,
|
|
height: height,
|
|
profile: UInt8(truncatingIfNeeded: doviProfile),
|
|
level: UInt8(truncatingIfNeeded: doviLevel),
|
|
compatibility: compat
|
|
)
|
|
}
|
|
|
|
let extensions: [CFString: Any]
|
|
switch dynamicRange {
|
|
case .hdr10:
|
|
extensions = [
|
|
kCMFormatDescriptionExtension_ColorPrimaries:
|
|
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
|
kCMFormatDescriptionExtension_TransferFunction:
|
|
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
|
kCMFormatDescriptionExtension_YCbCrMatrix:
|
|
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
|
]
|
|
case .hlg:
|
|
extensions = [
|
|
kCMFormatDescriptionExtension_ColorPrimaries:
|
|
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
|
kCMFormatDescriptionExtension_TransferFunction:
|
|
kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG,
|
|
kCMFormatDescriptionExtension_YCbCrMatrix:
|
|
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
|
]
|
|
case .sdr:
|
|
extensions = [
|
|
kCMFormatDescriptionExtension_ColorPrimaries:
|
|
kCMFormatDescriptionColorPrimaries_ITU_R_709_2,
|
|
kCMFormatDescriptionExtension_TransferFunction:
|
|
kCMFormatDescriptionTransferFunction_ITU_R_709_2,
|
|
kCMFormatDescriptionExtension_YCbCrMatrix:
|
|
kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2,
|
|
]
|
|
case .dolbyVision:
|
|
return nil
|
|
}
|
|
|
|
var fd: CMVideoFormatDescription?
|
|
let status = CMVideoFormatDescriptionCreate(
|
|
allocator: kCFAllocatorDefault,
|
|
codecType: kCMVideoCodecType_HEVC,
|
|
width: width,
|
|
height: height,
|
|
extensions: extensions as CFDictionary,
|
|
formatDescriptionOut: &fd
|
|
)
|
|
return status == noErr ? fd : nil
|
|
}
|
|
|
|
/// Build a synthetic 'dvh1' `CMVideoFormatDescription` from the Dolby Vision
|
|
/// metadata mpv exposes. Used solely as a hint object for
|
|
/// `AVDisplayCriteria(refreshRate:formatDescription:)` — it is never
|
|
/// enqueued onto the sample-buffer layer.
|
|
private static func makeDolbyVisionFormatDescription(
|
|
width: Int32,
|
|
height: Int32,
|
|
profile: UInt8,
|
|
level: UInt8,
|
|
compatibility: UInt8
|
|
) -> CMVideoFormatDescription? {
|
|
// 24-byte Dolby Vision configuration record (dvcC ≤ profile 7, dvvC ≥ 8).
|
|
// Layout from ETSI TS 103 572 §7.1.1 — same packing as FFmpeg's
|
|
// videotoolbox_dovi_extradata_create (in 0002 patch):
|
|
// [0] dv_version_major (= 1)
|
|
// [1] dv_version_minor (= 0)
|
|
// [2..3] big-endian uint16: profile<<9 | level<<3 | rpu<<2 | el<<1 | bl
|
|
// [4] compatibility<<4 | md_compression<<2
|
|
// [5..23] reserved zero
|
|
var dovi = [UInt8](repeating: 0, count: 24)
|
|
dovi[0] = 1
|
|
dovi[1] = 0
|
|
let flags: UInt16 =
|
|
(UInt16(profile) & 0x7f) << 9
|
|
| (UInt16(level) & 0x3f) << 3
|
|
| (1 << 2) // rpu_present_flag
|
|
| (1 << 0) // bl_present_flag
|
|
dovi[2] = UInt8((flags >> 8) & 0xff)
|
|
dovi[3] = UInt8(flags & 0xff)
|
|
dovi[4] = (compatibility & 0x0f) << 4
|
|
|
|
// CoreMedia carries codec-specific boxes under
|
|
// kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms.
|
|
let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString
|
|
let atoms: [CFString: Any] = [recordKey: Data(dovi) as CFData]
|
|
|
|
let extensions: [CFString: Any] = [
|
|
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: atoms as CFDictionary,
|
|
kCMFormatDescriptionExtension_ColorPrimaries:
|
|
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
|
kCMFormatDescriptionExtension_TransferFunction:
|
|
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
|
kCMFormatDescriptionExtension_YCbCrMatrix:
|
|
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
|
]
|
|
|
|
var fd: CMVideoFormatDescription?
|
|
let status = CMVideoFormatDescriptionCreate(
|
|
allocator: kCFAllocatorDefault,
|
|
codecType: kCMVideoCodecType_DolbyVisionHEVC, // 'dvh1'
|
|
width: width,
|
|
height: height,
|
|
extensions: extensions as CFDictionary,
|
|
formatDescriptionOut: &fd
|
|
)
|
|
return status == noErr ? fd : nil
|
|
}
|
|
#endif
|
|
|
|
func dispose(preserveDisplayCriteria: Bool = false) {
|
|
guard beginDisposal() else { return }
|
|
|
|
#if os(tvOS)
|
|
if preserveDisplayCriteria {
|
|
Self.log("dispose preserving display criteria (key: \(Self.activeDisplayCriteriaKey ?? "nil"))")
|
|
}
|
|
#endif
|
|
|
|
// Reset the HDMI mode hint synchronously while self is still alive
|
|
// and on main. An async-to-main dispatch here would be drained after
|
|
// dealloc (the plugin sets playerCore = nil right after this call
|
|
// returns), leaving the link stuck at the last clip's refresh rate.
|
|
// During video-to-video replacement, keep the hint so tvOS doesn't
|
|
// renegotiate back to default before the replacement route can set its
|
|
// next criteria.
|
|
if !preserveDisplayCriteria {
|
|
updateDisplayCriteria(
|
|
doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil,
|
|
fps: 0, width: 0, height: 0, sigPeak: 0,
|
|
gamma: nil, primaries: nil, colorMatrix: nil)
|
|
}
|
|
|
|
#if os(tvOS)
|
|
displayModeSwitchWaiter?.cancel(complete: true)
|
|
displayModeSwitchWaiter = nil
|
|
#endif
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
#if os(iOS)
|
|
ExternalDisplayManager.shared.detach(core: self)
|
|
#endif
|
|
disposeSharedState(destroySynchronously: false)
|
|
|
|
videoLayer?.removeFromSuperlayer()
|
|
videoLayer = nil
|
|
containerView?.removeFromSuperview()
|
|
containerView = nil
|
|
mainBlankView?.removeFromSuperview()
|
|
mainBlankView = nil
|
|
isInitialized = false
|
|
|
|
Self.log("Disposed")
|
|
}
|
|
|
|
deinit {
|
|
dispose()
|
|
}
|
|
|
|
private func setupNotifications() {
|
|
#if os(iOS)
|
|
let scene = window?.windowScene
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(enterBackground),
|
|
name: UIScene.didEnterBackgroundNotification,
|
|
object: scene
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(enterForeground),
|
|
name: UIScene.willEnterForegroundNotification,
|
|
object: scene
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(sceneDidActivate),
|
|
name: UIScene.didActivateNotification,
|
|
object: scene
|
|
)
|
|
#else
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(enterBackground),
|
|
name: UIApplication.didEnterBackgroundNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(enterForeground),
|
|
name: UIApplication.willEnterForegroundNotification,
|
|
object: nil
|
|
)
|
|
#endif
|
|
}
|
|
|
|
@objc private func enterBackground() {
|
|
setBackgrounded(true)
|
|
if isPipActive || isPipStarting {
|
|
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
|
return
|
|
}
|
|
|
|
print("[MpvPlayerCore] Entering background - disabling video")
|
|
setProperty("vid", value: "no")
|
|
}
|
|
|
|
@objc private func enterForeground() {
|
|
setBackgrounded(false)
|
|
if isPipActive {
|
|
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
|
return
|
|
}
|
|
|
|
print("[MpvPlayerCore] Entering foreground - enabling video")
|
|
setProperty("vid", value: "auto")
|
|
}
|
|
|
|
#if os(iOS)
|
|
@objc private func sceneDidActivate() {
|
|
setBackgrounded(false)
|
|
if isPipActive {
|
|
return
|
|
}
|
|
|
|
setProperty("vid", value: "auto")
|
|
restoreVideoPresentation()
|
|
}
|
|
#endif
|
|
}
|