mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 19:05:03 +00:00
Add tvOS display frame rate and dynamic range matching
Lets the Apple TV switch its HDMI output to match the playing video's frame rate and dynamic range via AVDisplayManager.preferredDisplayCriteria, driven from MPV's container-fps and video-params/gamma. Two opt-in toggles (default off) live under Playback → Display on tvOS; both are no-ops on other platforms. Anchor an AVKit class symbol so the linker keeps AVKit linked — Swift only autolinks AVFoundation here, and without AVKit the UIWindow.avDisplayManager category isn't loaded at runtime.
This commit is contained in:
187
Yattee/Services/Player/TVDisplayModeManager.swift
Normal file
187
Yattee/Services/Player/TVDisplayModeManager.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// TVDisplayModeManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Drives AVDisplayManager.preferredDisplayCriteria on tvOS so the Apple TV
|
||||
// switches its HDMI output to match the playing video's frame rate and
|
||||
// dynamic range. Independent of the playback engine — works alongside MPV.
|
||||
//
|
||||
// The user must also have "Match Content → Frame Rate / Dynamic Range" enabled
|
||||
// in tvOS Settings → Video and Audio for the system to honor these criteria.
|
||||
//
|
||||
|
||||
#if os(tvOS)
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import CoreMedia
|
||||
import UIKit
|
||||
|
||||
enum TVDisplayDynamicRange {
|
||||
case sdr
|
||||
case hdr10
|
||||
case hlg
|
||||
|
||||
var transferFunction: CFString {
|
||||
switch self {
|
||||
case .sdr: return kCMFormatDescriptionTransferFunction_ITU_R_709_2
|
||||
case .hdr10: return kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ
|
||||
case .hlg: return kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG
|
||||
}
|
||||
}
|
||||
|
||||
var colorPrimaries: CFString {
|
||||
switch self {
|
||||
case .sdr: return kCMFormatDescriptionColorPrimaries_ITU_R_709_2
|
||||
case .hdr10, .hlg: return kCMFormatDescriptionColorPrimaries_ITU_R_2020
|
||||
}
|
||||
}
|
||||
|
||||
var yCbCrMatrix: CFString {
|
||||
switch self {
|
||||
case .sdr: return kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2
|
||||
case .hdr10, .hlg: return kCMFormatDescriptionYCbCrMatrix_ITU_R_2020
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps an MPV `video-params/gamma` string to the closest tvOS dynamic-range bucket.
|
||||
/// MPV exposes gammas like `bt.1886`, `srgb`, `pq`, `hlg`. Anything we don't recognize
|
||||
/// is treated as SDR.
|
||||
func tvDisplayDynamicRange(fromMPVGamma gamma: String?) -> TVDisplayDynamicRange {
|
||||
switch gamma?.lowercased() {
|
||||
case "pq", "smpte2084", "st2084":
|
||||
return .hdr10
|
||||
case "hlg", "arib-std-b67":
|
||||
return .hlg
|
||||
default:
|
||||
return .sdr
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TVDisplayModeManager {
|
||||
static let shared = TVDisplayModeManager()
|
||||
|
||||
/// Anchors a real AVKit ObjC class symbol so the linker keeps AVKit linked.
|
||||
/// Without this, AVKit's UIWindow category that adds `avDisplayManager` is
|
||||
/// not loaded at runtime and the selector crashes — Swift only autolinks
|
||||
/// AVFoundation here because `AVDisplayCriteria` lives there.
|
||||
private static let _avkitLinkAnchor: AnyClass = AVDisplayManager.self
|
||||
|
||||
private var hasAppliedCriteria = false
|
||||
|
||||
private init() {
|
||||
_ = Self._avkitLinkAnchor
|
||||
}
|
||||
|
||||
/// Apply preferred display criteria for the given video parameters.
|
||||
/// Pass `nil` for fields you don't have yet; this method is safe to call
|
||||
/// repeatedly as more info becomes available (e.g. fps arrives before gamma).
|
||||
func apply(fps: Double?, gamma: String?) {
|
||||
let matchFrameRate = readBoolDefaultFalse(key: "tvMatchDisplayFrameRate")
|
||||
let matchDynamicRange = readBoolDefaultFalse(key: "tvMatchDisplayDynamicRange")
|
||||
|
||||
guard matchFrameRate || matchDynamicRange else {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
let refreshRate: Float = (matchFrameRate ? Float(fps ?? 0) : 0)
|
||||
let dynamicRange = matchDynamicRange ? tvDisplayDynamicRange(fromMPVGamma: gamma) : nil
|
||||
|
||||
// If we have neither dimension to actually request, no-op.
|
||||
guard refreshRate > 0 || dynamicRange != nil else { return }
|
||||
|
||||
guard let manager = activeDisplayManager() else {
|
||||
LoggingService.shared.debug(
|
||||
"TVDisplayMode: no AVDisplayManager available (no UIWindowScene yet)",
|
||||
category: .mpv
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let criteria: AVDisplayCriteria
|
||||
if let dynamicRange {
|
||||
let formatDescription = makeFormatDescription(for: dynamicRange)
|
||||
criteria = AVDisplayCriteria(
|
||||
refreshRate: refreshRate,
|
||||
formatDescription: formatDescription
|
||||
)
|
||||
} else {
|
||||
// Frame-rate only: use BT.709/SDR format description so the system
|
||||
// doesn't switch dynamic range.
|
||||
let formatDescription = makeFormatDescription(for: .sdr)
|
||||
criteria = AVDisplayCriteria(
|
||||
refreshRate: refreshRate,
|
||||
formatDescription: formatDescription
|
||||
)
|
||||
}
|
||||
|
||||
manager.preferredDisplayCriteria = criteria
|
||||
hasAppliedCriteria = true
|
||||
LoggingService.shared.debug(
|
||||
"TVDisplayMode: applied refreshRate=\(refreshRate), dynamicRange=\(dynamicRange.map(String.init(describing:)) ?? "nil")",
|
||||
category: .mpv
|
||||
)
|
||||
}
|
||||
|
||||
/// Clear any previously-applied criteria so tvOS reverts to the user's default mode.
|
||||
func clear() {
|
||||
guard hasAppliedCriteria else { return }
|
||||
hasAppliedCriteria = false
|
||||
guard let manager = activeDisplayManager() else { return }
|
||||
manager.preferredDisplayCriteria = nil
|
||||
LoggingService.shared.debug("TVDisplayMode: cleared", category: .mpv)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func readBoolDefaultFalse(key: String) -> Bool {
|
||||
// The SettingsManager stores these unprefixed (not platform-specific keys),
|
||||
// so we can read them directly from standard UserDefaults. Missing key = off.
|
||||
UserDefaults.standard.bool(forKey: key)
|
||||
}
|
||||
|
||||
private func activeDisplayManager() -> AVDisplayManager? {
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
let scene = scenes.first { $0.activationState == .foregroundActive }
|
||||
?? scenes.first
|
||||
let window = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
|
||||
return window?.avDisplayManager
|
||||
}
|
||||
|
||||
private func makeFormatDescription(for dynamicRange: TVDisplayDynamicRange) -> CMVideoFormatDescription {
|
||||
let extensions: [CFString: Any] = [
|
||||
kCMFormatDescriptionExtension_TransferFunction: dynamicRange.transferFunction,
|
||||
kCMFormatDescriptionExtension_ColorPrimaries: dynamicRange.colorPrimaries,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix: dynamicRange.yCbCrMatrix
|
||||
]
|
||||
|
||||
var description: CMVideoFormatDescription?
|
||||
CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCMVideoCodecType_HEVC,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
extensions: extensions as CFDictionary,
|
||||
formatDescriptionOut: &description
|
||||
)
|
||||
// CMVideoFormatDescriptionCreate cannot meaningfully fail with these inputs,
|
||||
// but if it does, fall back to a minimal description AVDisplayCriteria can use.
|
||||
if let description {
|
||||
return description
|
||||
}
|
||||
var fallback: CMVideoFormatDescription?
|
||||
CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCMVideoCodecType_HEVC,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
extensions: nil,
|
||||
formatDescriptionOut: &fallback
|
||||
)
|
||||
return fallback!
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user