From ae1f6d7822f2ddc73519dc683b406096e8a72e54 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 12 May 2026 16:13:45 +0200 Subject: [PATCH] fix(tvOS): trigger native Dolby Vision via preferredDisplayCriteria --- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 109 ++++++++++++++++++ .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 103 +++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 9a0ffaf4..99215d58 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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) diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 4cac6e71..12b40bfb 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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 = [ @@ -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 = [ 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 }