diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index ea233e61..21b50d53 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -301,6 +301,23 @@ extension SettingsView { #endif } + // Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh to allow. + @ViewBuilder var vrrSection: some View { + #if !os(tvOS) + Section { + Toggle("Allow VRR", isOn: $allowVRR) + } header: { + Text("Variable refresh rate") + } footer: { + Text("Let a ProMotion or adaptive-sync display vary its refresh rate to match the " + + "stream — smoother motion without tearing. No effect on fixed-refresh displays. " + + "Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + #endif + } + // macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice only // exists on the Mac (where the layer's own sync must stay off — see MetalVideoPresenter). @ViewBuilder var vsyncSection: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 7ea6b83c..28796b01 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -28,6 +28,9 @@ struct SettingsView: View { #if os(macOS) @AppStorage(DefaultsKey.vsync) var vsync = false #endif + #if !os(tvOS) + @AppStorage(DefaultsKey.allowVRR) var allowVRR = true + #endif @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.enable444) var enable444 = true @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false @@ -109,6 +112,7 @@ struct SettingsView: View { Form { presenterSection hdrSection + vrrSection vsyncSection windowSection statisticsSection @@ -238,6 +242,7 @@ struct SettingsView: View { Form { presenterSection hdrSection + vrrSection statisticsSection } .formStyle(.grouped) diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 47dc1091..b66186d2 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -36,6 +36,12 @@ public enum DefaultsKey { /// (lowest latency — the default, OFF). Resolved once per session; /// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header. public static let vsync = "punktfunk.vsync" + /// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor, + /// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical + /// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off, + /// macOS lets the link free-run at the display's native rate and iOS keeps its proven 30 Hz + /// floor. Read per session/reconfigure by `SessionPresenter.syncFrameRate`. + public static let allowVRR = "punktfunk.allowVRR" /// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host /// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR. public static let hdrEnabled = "punktfunk.hdrEnabled" diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index ff337a75..d6512fed 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -90,23 +90,37 @@ final class SessionPresenter { } } - /// Ask the display link for the stream's own cadence. iOS/tvOS-only: without an explicit - /// range, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need + /// Hint the display link with the stream's cadence. On iOS/tvOS a range is always required: + /// without one, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need /// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present - /// at half rate with the ring silently dropping every other frame. `maximum` allows up to - /// 120 so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free - /// empty `renderTick`, and presenting on a denser grid shortens the decode→glass wait); the - /// macOS NSView link already tracks its display and must NOT be capped to the stream rate. + /// at half rate with the ring silently dropping every other frame. `maximum` allows up to 120 + /// so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free empty + /// `renderTick`, and presenting on a denser grid shortens the decode→glass wait). + /// + /// The `allowVRR` setting (default on) widens that hint into a true variable-refresh request: + /// `preferred` = the stream rate with a low floor, so a ProMotion / adaptive-sync display can + /// drop its physical refresh to match the content. With VRR off we fall back to the proven + /// behavior — iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native + /// rate (it already tracks the display and must NOT be capped to the stream rate). /// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh. private func syncFrameRate(hz: UInt32) { - #if !os(macOS) guard hz > 0, let link = stage2Link else { return } let hzF = Float(hz) - if link.preferredFrameRateRange.preferred != hzF { - link.preferredFrameRateRange = CAFrameRateRange( - minimum: min(30, hzF), maximum: max(hzF, 120), preferred: hzF) - } + let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true + #if os(macOS) + // Off: `.default` = the link free-runs at the display's native rate (pre-VRR behavior). + // On: request the content rate with a 24 Hz floor — capped at the display, never at the + // stream rate, so an adaptive-sync panel can track the stream. + let range: CAFrameRateRange = allowVRR + ? CAFrameRateRange(minimum: min(hzF, 24), maximum: max(hzF, 120), preferred: hzF) + : .default + #else + // A range is mandatory here (see above); VRR only lowers the floor (24 vs 30) so the + // panel can drop deeper to match content on a sub-rate or momentarily stalling stream. + let floor = allowVRR ? min(hzF, 24) : min(hzF, 30) + let range = CAFrameRateRange(minimum: floor, maximum: max(hzF, 120), preferred: hzF) #endif + if link.preferredFrameRateRange != range { link.preferredFrameRateRange = range } } /// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the