diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index fe2c119d..04234d15 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -108,6 +108,8 @@ enum SettingsKey: String, CaseIterable { case mpvBufferSeconds case mpvUseEDLStreams case zoomTransitionsEnabled + case tvMatchDisplayFrameRate + case tvMatchDisplayDynamicRange // Details panel case floatingDetailsPanelSide // Landscape only - which side the panel appears on diff --git a/Yattee/Core/Settings/SettingsManager+Advanced.swift b/Yattee/Core/Settings/SettingsManager+Advanced.swift index eb03971e..6f82d6f7 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -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). /// 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. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 7548842c..0b4b033b 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -158,6 +158,8 @@ final class SettingsManager { var _mpvBufferSeconds: Double? var _mpvUseEDLStreams: Bool? var _zoomTransitionsEnabled: Bool? + var _tvMatchDisplayFrameRate: Bool? + var _tvMatchDisplayDynamicRange: Bool? // Details panel settings var _floatingDetailsPanelSide: FloatingPanelSide? @@ -492,6 +494,8 @@ final class SettingsManager { _mpvBufferSeconds = nil _mpvUseEDLStreams = nil _zoomTransitionsEnabled = nil + _tvMatchDisplayFrameRate = nil + _tvMatchDisplayDynamicRange = nil _floatingDetailsPanelSide = nil _floatingDetailsPanelWidth = nil _landscapeDetailsPanelVisible = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index d0a0486a..6ee2f362 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { "localizations" : { "en" : { diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 5b2d4947..071bc631 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -545,6 +545,8 @@ final class MPVClient: @unchecked Sendable { observeProperty("video-codec", format: MPV_FORMAT_STRING) observeProperty("hwdec-current", 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 diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 9bb7683c..6bab81e9 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -133,6 +133,9 @@ final class MPVBackend: PlayerBackend { private var videoCodec: String = "" private var hwdecCurrent: String = "" private var hwdecInterop: String = "" + #if os(tvOS) + private var videoGamma: String = "" + #endif // Async initialization tracking private var setupTask: Task? @@ -753,6 +756,10 @@ final class MPVBackend: PlayerBackend { duration = 0 bufferedTime = 0 currentStream = nil + #if os(tvOS) + videoGamma = "" + clearTVDisplayCriteria() + #endif delegate?.backend(self, didChangeState: .idle) } @@ -1631,8 +1638,19 @@ extension MPVBackend: MPVClientDelegate { if let fps = value as? Double, fps > 0 { containerFps = fps 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": if let isPausedForCache = value as? Bool { pausedForCache = isPausedForCache @@ -1706,6 +1724,25 @@ extension MPVBackend: MPVClientDelegate { 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 private func updateRenderViewFPS() { // 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 interop = hwdecInterop.isEmpty ? "none" : hwdecInterop LoggingService.shared.debug("MPV: Video codec: \(codec), hwdec-current: \(hwdec), hwdec-interop: \(interop)", category: .mpv) + applyTVDisplayCriteria() #endif #if os(iOS) || os(tvOS) reactivateAudioSession() diff --git a/Yattee/Services/Player/TVDisplayModeManager.swift b/Yattee/Services/Player/TVDisplayModeManager.swift new file mode 100644 index 00000000..081abe94 --- /dev/null +++ b/Yattee/Services/Player/TVDisplayModeManager.swift @@ -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 diff --git a/Yattee/Views/Settings/PlaybackSettingsView.swift b/Yattee/Views/Settings/PlaybackSettingsView.swift index 19c7a8d2..3702a7d8 100644 --- a/Yattee/Views/Settings/PlaybackSettingsView.swift +++ b/Yattee/Views/Settings/PlaybackSettingsView.swift @@ -17,6 +17,9 @@ struct PlaybackSettingsView: View { AudioSection(settings: settings) SubtitlesSection(settings: settings) BehaviorSection(settings: settings) + #if os(tvOS) + TVDisplayMatchingSection(settings: settings) + #endif QueueSection(settings: settings) #if os(iOS) 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 private struct QueueSection: View {