fix(tvOS): trigger native Dolby Vision via preferredDisplayCriteria

This commit is contained in:
edde746
2026-05-12 16:21:00 +02:00
parent 4577f26aa8
commit ae1f6d7822
2 changed files with 212 additions and 0 deletions
+109
View File
@@ -204,6 +204,115 @@ class MpvPlayerCore: MpvPlayerCoreBase {
)
}
override func updateDisplayCriteria(
doviProfile: Int64,
doviLevel: Int64,
fps: Double,
width: Int32,
height: Int32,
sigPeak: Double
) {
#if os(tvOS)
guard let window = containerView?.window ?? self.window else { return }
let displayManager = window.avDisplayManager
guard displayManager.isDisplayCriteriaMatchingEnabled else { return }
let refreshRate = Float(fps > 0 ? fps : 0)
if doviProfile > 0, width > 0, height > 0, #available(tvOS 17.0, *) {
// 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 (and matches the user's reported content).
let compat: UInt8 = doviProfile == 8 ? 1 : 0
if let fd = Self.makeDolbyVisionFormatDescription(
width: width,
height: height,
profile: UInt8(truncatingIfNeeded: doviProfile),
level: UInt8(truncatingIfNeeded: doviLevel),
compatibility: compat)
{
let criteria = AVDisplayCriteria(refreshRate: refreshRate, formatDescription: fd)
displayManager.preferredDisplayCriteria = criteria
print(
"[MpvPlayerCore] preferredDisplayCriteria set to Dolby Vision (profile: \(doviProfile), level: \(doviLevel), fps: \(refreshRate), \(width)x\(height))"
)
return
}
print("[MpvPlayerCore] Failed to synthesize DV CMVideoFormatDescription; clearing criteria")
}
// Non-DV content (HDR10 / SDR) or DV FD synthesis failed: clear the
// hint and let tvOS auto-pick from the AVSampleBufferDisplayLayer's
// actual sample-buffer attachments (BT.2020 + PQ for HDR10).
if displayManager.preferredDisplayCriteria != nil {
displayManager.preferredDisplayCriteria = nil
print("[MpvPlayerCore] preferredDisplayCriteria cleared (sigPeak: \(sigPeak))")
}
#endif
}
#if os(tvOS)
/// 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 does not export a typed
// `kCMFormatDescriptionExtension_DolbyVision` constant. The well-known
// CFString key is the four-char box name VideoToolbox/AVFoundation
// expect (same key FFmpeg writes in 0002-videotoolbox-add-dolby-vision-hevc-format.patch).
let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString
let extensions: [CFString: Any] = [
recordKey: Data(dovi) as CFData,
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() {
NotificationCenter.default.removeObserver(self)
#if os(iOS)
@@ -76,6 +76,9 @@ class MpvPlayerCoreBase: NSObject {
var isBackgrounded = false
private var cachedHDREnabled = true
private var cachedLastSigPeak = 0.0
private var cachedDoviProfile: Int64 = 0
private var cachedDoviLevel: Int64 = 0
private var cachedContainerFps: Double = 0
var hdrEnabled: Bool {
cacheLock.lock()
defer { cacheLock.unlock() }
@@ -86,6 +89,21 @@ class MpvPlayerCoreBase: NSObject {
defer { cacheLock.unlock() }
return cachedLastSigPeak
}
var doviProfile: Int64 {
cacheLock.lock()
defer { cacheLock.unlock() }
return cachedDoviProfile
}
var doviLevel: Int64 {
cacheLock.lock()
defer { cacheLock.unlock() }
return cachedDoviLevel
}
var containerFps: Double {
cacheLock.lock()
defer { cacheLock.unlock() }
return cachedContainerFps
}
/// Properties that must still flow to Dart while backgrounded (state-critical).
private static let criticalProperties: Set<String> = [
@@ -95,10 +113,16 @@ class MpvPlayerCoreBase: NSObject {
private static let internalSigPeakObserverId: UInt64 = UInt64.max - 1
private static let internalWidthObserverId: UInt64 = UInt64.max - 2
private static let internalHeightObserverId: UInt64 = UInt64.max - 3
private static let internalDoviProfileObserverId: UInt64 = UInt64.max - 4
private static let internalDoviLevelObserverId: UInt64 = UInt64.max - 5
private static let internalContainerFpsObserverId: UInt64 = UInt64.max - 6
private static let internalObserverIds: Set<UInt64> = [
internalSigPeakObserverId,
internalWidthObserverId,
internalHeightObserverId,
internalDoviProfileObserverId,
internalDoviLevelObserverId,
internalContainerFpsObserverId,
]
let queue = DispatchQueue(label: "mpv", qos: .userInitiated)
@@ -131,6 +155,37 @@ class MpvPlayerCoreBase: NSObject {
func updateEDRMode(sigPeak: Double) {}
func updateDisplayCriteria(
doviProfile: Int64,
doviLevel: Int64,
fps: Double,
width: Int32,
height: Int32,
sigPeak: Double
) {}
func scheduleDisplayCriteriaUpdate() {
cacheLock.lock()
let profile = cachedDoviProfile
let level = cachedDoviLevel
let fps = cachedContainerFps
let width = Int32(cachedWidth)
let height = Int32(cachedHeight)
let sigPeak = cachedLastSigPeak
cacheLock.unlock()
DispatchQueue.main.async { [weak self] in
self?.updateDisplayCriteria(
doviProfile: profile,
doviLevel: level,
fps: fps,
width: width,
height: height,
sigPeak: sigPeak
)
}
}
func setupMpv() -> Bool {
#if os(macOS)
guard let renderLayer = metalLayer else { return false }
@@ -176,6 +231,15 @@ class MpvPlayerCoreBase: NSObject {
mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE)
mpv_observe_property(
mpv, Self.internalDoviProfileObserverId,
"current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalDoviLevelObserverId,
"current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalContainerFpsObserverId,
"container-fps", MPV_FORMAT_DOUBLE)
return true
}
@@ -369,6 +433,17 @@ class MpvPlayerCoreBase: NSObject {
isDisposing = true
cancelPendingRequests()
cacheLock.lock()
cachedDoviProfile = 0
cachedDoviLevel = 0
cachedContainerFps = 0
cachedLastSigPeak = 0
cacheLock.unlock()
DispatchQueue.main.async { [weak self] in
self?.updateDisplayCriteria(
doviProfile: 0, doviLevel: 0, fps: 0, width: 0, height: 0, sigPeak: 0)
}
let mpvHandle = mpv
mpv = nil
@@ -638,6 +713,11 @@ class MpvPlayerCoreBase: NSObject {
value = data.assumingMemoryBound(to: Double.self).pointee
}
case MPV_FORMAT_INT64:
if let data = property.data {
value = data.assumingMemoryBound(to: Int64.self).pointee
}
case MPV_FORMAT_FLAG:
if let data = property.data {
value = data.assumingMemoryBound(to: Int32.self).pointee != 0
@@ -668,6 +748,29 @@ class MpvPlayerCoreBase: NSObject {
DispatchQueue.main.async {
self.updateEDRMode(sigPeak: sigPeak)
}
scheduleDisplayCriteriaUpdate()
}
switch name {
case "current-tracks/video/dolby-vision-profile":
cacheLock.lock()
cachedDoviProfile = (value as? Int64) ?? 0
cacheLock.unlock()
scheduleDisplayCriteriaUpdate()
case "current-tracks/video/dolby-vision-level":
cacheLock.lock()
cachedDoviLevel = (value as? Int64) ?? 0
cacheLock.unlock()
scheduleDisplayCriteriaUpdate()
case "container-fps":
cacheLock.lock()
cachedContainerFps = (value as? Double) ?? 0
cacheLock.unlock()
scheduleDisplayCriteriaUpdate()
case "width", "height":
scheduleDisplayCriteriaUpdate()
default:
break
}
if Self.internalObserverIds.contains(replyUserdata) { return }