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:
Arkadiusz Fal
2026-05-10 01:30:55 +02:00
parent 100e762d4b
commit 6a343311ea
8 changed files with 333 additions and 0 deletions

View File

@@ -108,6 +108,8 @@ enum SettingsKey: String, CaseIterable {
case mpvBufferSeconds case mpvBufferSeconds
case mpvUseEDLStreams case mpvUseEDLStreams
case zoomTransitionsEnabled case zoomTransitionsEnabled
case tvMatchDisplayFrameRate
case tvMatchDisplayDynamicRange
// Details panel // Details panel
case floatingDetailsPanelSide // Landscape only - which side the panel appears on case floatingDetailsPanelSide // Landscape only - which side the panel appears on

View File

@@ -141,6 +141,36 @@ extension SettingsManager {
} }
} }
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's frame rate. Has no effect unless the user also enables
/// "Match Content Frame Rate" in tvOS Settings Video and Audio.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayFrameRate: Bool {
get {
if let cached = _tvMatchDisplayFrameRate { return cached }
return bool(for: .tvMatchDisplayFrameRate, default: false)
}
set {
_tvMatchDisplayFrameRate = newValue
set(newValue, for: .tvMatchDisplayFrameRate)
}
}
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's dynamic range (SDR / HDR10 / HLG). Has no effect unless the
/// user also enables "Match Content Dynamic Range" in tvOS Settings.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayDynamicRange: Bool {
get {
if let cached = _tvMatchDisplayDynamicRange { return cached }
return bool(for: .tvMatchDisplayDynamicRange, default: false)
}
set {
_tvMatchDisplayDynamicRange = newValue
set(newValue, for: .tvMatchDisplayDynamicRange)
}
}
/// Whether zoom navigation transitions are enabled (iOS only). /// Whether zoom navigation transitions are enabled (iOS only).
/// When enabled, navigating to video/channel/playlist details shows a zoom animation /// When enabled, navigating to video/channel/playlist details shows a zoom animation
/// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures. /// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures.

View File

@@ -158,6 +158,8 @@ final class SettingsManager {
var _mpvBufferSeconds: Double? var _mpvBufferSeconds: Double?
var _mpvUseEDLStreams: Bool? var _mpvUseEDLStreams: Bool?
var _zoomTransitionsEnabled: Bool? var _zoomTransitionsEnabled: Bool?
var _tvMatchDisplayFrameRate: Bool?
var _tvMatchDisplayDynamicRange: Bool?
// Details panel settings // Details panel settings
var _floatingDetailsPanelSide: FloatingPanelSide? var _floatingDetailsPanelSide: FloatingPanelSide?
@@ -492,6 +494,8 @@ final class SettingsManager {
_mpvBufferSeconds = nil _mpvBufferSeconds = nil
_mpvUseEDLStreams = nil _mpvUseEDLStreams = nil
_zoomTransitionsEnabled = nil _zoomTransitionsEnabled = nil
_tvMatchDisplayFrameRate = nil
_tvMatchDisplayDynamicRange = nil
_floatingDetailsPanelSide = nil _floatingDetailsPanelSide = nil
_floatingDetailsPanelWidth = nil _floatingDetailsPanelWidth = nil
_landscapeDetailsPanelVisible = nil _landscapeDetailsPanelVisible = nil

View File

@@ -12372,6 +12372,48 @@
} }
} }
}, },
"settings.playback.tvDisplayMatching.dynamicRange" : {
"comment" : "Toggle label (tvOS only)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Match Dynamic Range"
}
}
}
},
"settings.playback.tvDisplayMatching.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Asks the Apple TV to switch its HDMI output mode to match the video being played. Also requires Match Content → Frame Rate / Dynamic Range to be enabled in tvOS Settings → Video and Audio."
}
}
}
},
"settings.playback.tvDisplayMatching.frameRate" : {
"comment" : "Toggle label (tvOS only)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Match Frame Rate"
}
}
}
},
"settings.playback.tvDisplayMatching.header" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Display"
}
}
}
},
"settings.playback.tvOSMenuButtonClosesVideo" : { "settings.playback.tvOSMenuButtonClosesVideo" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -545,6 +545,8 @@ final class MPVClient: @unchecked Sendable {
observeProperty("video-codec", format: MPV_FORMAT_STRING) observeProperty("video-codec", format: MPV_FORMAT_STRING)
observeProperty("hwdec-current", format: MPV_FORMAT_STRING) observeProperty("hwdec-current", format: MPV_FORMAT_STRING)
observeProperty("hwdec-interop", format: MPV_FORMAT_STRING) observeProperty("hwdec-interop", format: MPV_FORMAT_STRING)
// Color transfer for tvOS dynamic-range matching (SDR / HDR10 / HLG)
observeProperty("video-params/gamma", format: MPV_FORMAT_STRING)
} }
// MARK: - Options // MARK: - Options

View File

@@ -133,6 +133,9 @@ final class MPVBackend: PlayerBackend {
private var videoCodec: String = "" private var videoCodec: String = ""
private var hwdecCurrent: String = "" private var hwdecCurrent: String = ""
private var hwdecInterop: String = "" private var hwdecInterop: String = ""
#if os(tvOS)
private var videoGamma: String = ""
#endif
// Async initialization tracking // Async initialization tracking
private var setupTask: Task<Void, Error>? private var setupTask: Task<Void, Error>?
@@ -753,6 +756,10 @@ final class MPVBackend: PlayerBackend {
duration = 0 duration = 0
bufferedTime = 0 bufferedTime = 0
currentStream = nil currentStream = nil
#if os(tvOS)
videoGamma = ""
clearTVDisplayCriteria()
#endif
delegate?.backend(self, didChangeState: .idle) delegate?.backend(self, didChangeState: .idle)
} }
@@ -1631,8 +1638,19 @@ extension MPVBackend: MPVClientDelegate {
if let fps = value as? Double, fps > 0 { if let fps = value as? Double, fps > 0 {
containerFps = fps containerFps = fps
updateRenderViewFPS() updateRenderViewFPS()
#if os(tvOS)
applyTVDisplayCriteria()
#endif
} }
#if os(tvOS)
case "video-params/gamma":
if let gamma = value as? String {
videoGamma = gamma
applyTVDisplayCriteria()
}
#endif
case "paused-for-cache": case "paused-for-cache":
if let isPausedForCache = value as? Bool { if let isPausedForCache = value as? Bool {
pausedForCache = isPausedForCache pausedForCache = isPausedForCache
@@ -1706,6 +1724,25 @@ extension MPVBackend: MPVClientDelegate {
checkAndMarkReadyIfVideoAvailable() checkAndMarkReadyIfVideoAvailable()
} }
#if os(tvOS)
/// Apply tvOS display-mode criteria (frame rate / dynamic range) based on the
/// currently-cached MPV video parameters. Safe to call repeatedly as more info
/// arrives (fps may land before gamma or vice versa).
private func applyTVDisplayCriteria() {
let fps = containerFps > 0 ? containerFps : nil
let gamma = videoGamma.isEmpty ? nil : videoGamma
Task { @MainActor in
TVDisplayModeManager.shared.apply(fps: fps, gamma: gamma)
}
}
private func clearTVDisplayCriteria() {
Task { @MainActor in
TVDisplayModeManager.shared.clear()
}
}
#endif
/// Update render view's video FPS for display link frame rate matching /// Update render view's video FPS for display link frame rate matching
private func updateRenderViewFPS() { private func updateRenderViewFPS() {
// Use cached container-fps (set via property observation to avoid sync fetch on main thread) // Use cached container-fps (set via property observation to avoid sync fetch on main thread)
@@ -1771,6 +1808,7 @@ extension MPVBackend: MPVClientDelegate {
let hwdec = hwdecCurrent.isEmpty ? "none" : hwdecCurrent let hwdec = hwdecCurrent.isEmpty ? "none" : hwdecCurrent
let interop = hwdecInterop.isEmpty ? "none" : hwdecInterop let interop = hwdecInterop.isEmpty ? "none" : hwdecInterop
LoggingService.shared.debug("MPV: Video codec: \(codec), hwdec-current: \(hwdec), hwdec-interop: \(interop)", category: .mpv) LoggingService.shared.debug("MPV: Video codec: \(codec), hwdec-current: \(hwdec), hwdec-interop: \(interop)", category: .mpv)
applyTVDisplayCriteria()
#endif #endif
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
reactivateAudioSession() reactivateAudioSession()

View 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

View File

@@ -17,6 +17,9 @@ struct PlaybackSettingsView: View {
AudioSection(settings: settings) AudioSection(settings: settings)
SubtitlesSection(settings: settings) SubtitlesSection(settings: settings)
BehaviorSection(settings: settings) BehaviorSection(settings: settings)
#if os(tvOS)
TVDisplayMatchingSection(settings: settings)
#endif
QueueSection(settings: settings) QueueSection(settings: settings)
#if os(iOS) #if os(iOS)
OrientationSection(settings: settings) OrientationSection(settings: settings)
@@ -286,6 +289,31 @@ private struct BehaviorSection: View {
} }
} }
// MARK: - TV Display Matching Section
#if os(tvOS)
private struct TVDisplayMatchingSection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection(
"settings.playback.tvDisplayMatching.header",
footer: "settings.playback.tvDisplayMatching.footer"
) {
Toggle(
String(localized: "settings.playback.tvDisplayMatching.frameRate"),
isOn: $settings.tvMatchDisplayFrameRate
)
Toggle(
String(localized: "settings.playback.tvDisplayMatching.dynamicRange"),
isOn: $settings.tvMatchDisplayDynamicRange
)
}
}
}
#endif
// MARK: - Queue Section // MARK: - Queue Section
private struct QueueSection: View { private struct QueueSection: View {