From c168fbae021f4748c7d510fe3803dd9f61e18e25 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 14 May 2026 09:43:25 +0200 Subject: [PATCH] Add tvOS A/V sync diagnostics on its own settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface audio-delay (±10/±100 ms) and video-sync-mode controls behind an "A/V Sync" navigation row in Advanced settings rather than inline, keeping the Advanced page uncluttered. Audio delay applies live to the running MPV instance; sync mode takes effect on next playback. --- .../xcshareddata/xcodecloud/manifest.json | 9 ++ Yattee/Core/Settings/SettingsKey.swift | 2 + .../Settings/SettingsManager+Advanced.swift | 46 ++++++ Yattee/Core/SettingsManager.swift | 4 + Yattee/Localizable.xcstrings | 134 ++++++++++++++++++ Yattee/Models/TVVideoSyncMode.swift | 29 ++++ Yattee/Services/Player/MPV/MPVClient.swift | 53 ++++++- Yattee/Services/Player/MPVBackend.swift | 14 ++ Yattee/Views/Player/MPVDebugOverlay.swift | 54 +++++++ .../Settings/AVSyncDiagnosticsView.swift | 128 +++++++++++++++++ .../Views/Settings/AdvancedSettingsView.swift | 23 +++ 11 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json create mode 100644 Yattee/Models/TVVideoSyncMode.swift create mode 100644 Yattee/Views/Settings/AVSyncDiagnosticsView.swift diff --git a/Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json b/Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..78b77b73 --- /dev/null +++ b/Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1", + "targets" : [ + { + "id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691", + "name" : "Yattee" + } + ] +} \ No newline at end of file diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 04234d15..b5e8ec09 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -110,6 +110,8 @@ enum SettingsKey: String, CaseIterable { case zoomTransitionsEnabled case tvMatchDisplayFrameRate case tvMatchDisplayDynamicRange + case tvAudioDelayMs + case tvVideoSyncMode // 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 6f82d6f7..88ca2129 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -156,6 +156,52 @@ extension SettingsManager { } } + /// Fixed audio-pipeline offset (in milliseconds) applied as MPV's `audio-delay` + /// on tvOS. Useful for compensating fixed HDMI/AVR output latency where MPV's + /// internal `avsync` reads 0 but audio is perceptibly ahead/behind video. + /// Positive values shift audio later; negative values shift it earlier. + /// Default is 0 (no offset). Setting is local-only and tvOS-only. + var tvAudioDelayMs: Double { + get { + if let cached = _tvAudioDelayMs { return cached } + let raw = localDefaults.object(forKey: "tvAudioDelayMs") as? Double + return raw ?? 0 + } + set { + _tvAudioDelayMs = newValue + localDefaults.set(newValue, forKey: "tvAudioDelayMs") + } + } + + /// Read tvOS audio-delay setting from a nonisolated context (e.g. MPVClient init). + /// Returns milliseconds; convert to seconds before passing to MPV. + nonisolated static func tvAudioDelayMsSync() -> Double { + guard UserDefaults.standard.object(forKey: "tvAudioDelayMs") != nil else { return 0 } + return UserDefaults.standard.double(forKey: "tvAudioDelayMs") + } + + /// MPV `video-sync` mode override for tvOS (debug toggle). The shipped default + /// remains `display-vdrop`; this is exposed so we can A/B alternative modes on + /// real hardware when investigating A/V sync issues. + var tvVideoSyncMode: TVVideoSyncMode { + get { + if let cached = _tvVideoSyncMode { return cached } + guard let raw = localDefaults.string(forKey: "tvVideoSyncMode"), + let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop } + return mode + } + set { + _tvVideoSyncMode = newValue + localDefaults.set(newValue.rawValue, forKey: "tvVideoSyncMode") + } + } + + nonisolated static func tvVideoSyncModeSync() -> TVVideoSyncMode { + guard let raw = UserDefaults.standard.string(forKey: "tvVideoSyncMode"), + let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop } + return mode + } + /// 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. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 0b4b033b..e3832b75 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -160,6 +160,8 @@ final class SettingsManager { var _zoomTransitionsEnabled: Bool? var _tvMatchDisplayFrameRate: Bool? var _tvMatchDisplayDynamicRange: Bool? + var _tvAudioDelayMs: Double? + var _tvVideoSyncMode: TVVideoSyncMode? // Details panel settings var _floatingDetailsPanelSide: FloatingPanelSide? @@ -496,6 +498,8 @@ final class SettingsManager { _zoomTransitionsEnabled = nil _tvMatchDisplayFrameRate = nil _tvMatchDisplayDynamicRange = nil + _tvAudioDelayMs = nil + _tvVideoSyncMode = nil _floatingDetailsPanelSide = nil _floatingDetailsPanelWidth = nil _landscapeDetailsPanelVisible = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 6ee2f362..3a53035b 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -12414,6 +12414,140 @@ } } }, + "settings.playback.tvAudioDelay" : { + "comment" : "Audio delay stepper label (tvOS only)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio Delay" + } + } + } + }, + "settings.playback.tvAudioDelay.minus100" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "−100 ms" + } + } + } + }, + "settings.playback.tvAudioDelay.minus10" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "−10 ms" + } + } + } + }, + "settings.playback.tvAudioDelay.plus10" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "+10 ms" + } + } + } + }, + "settings.playback.tvAudioDelay.plus100" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "+100 ms" + } + } + } + }, + "settings.playback.tvAudioDelay.reset" : { + "comment" : "Reset audio delay button label (tvOS only)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + } + } + }, + "settings.playback.tvSyncDiagnostics.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A/V Sync Diagnostics" + } + } + } + }, + "settings.playback.tvSyncDiagnostics.row" : { + "comment" : "Advanced settings navigation row label (tvOS only)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A/V Sync" + } + } + } + }, + "settings.playback.tvSyncDiagnostics.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio Delay shifts audio relative to video to compensate for fixed HDMI/AVR output lag (positive = audio later). Video Sync Mode changes how MPV reconciles video frames with display refresh; the default is recommended unless you are debugging." + } + } + } + }, + "settings.playback.tvVideoSyncMode" : { + "comment" : "Picker label (tvOS only)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Video Sync Mode" + } + } + } + }, + "settings.playback.tvVideoSyncMode.displayVdrop" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display (drop/repeat) — default" + } + } + } + }, + "settings.playback.tvVideoSyncMode.displayResample" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display (resample audio)" + } + } + } + }, + "settings.playback.tvVideoSyncMode.audio" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio (video adapts)" + } + } + } + }, "settings.playback.tvOSMenuButtonClosesVideo" : { "localizations" : { "en" : { diff --git a/Yattee/Models/TVVideoSyncMode.swift b/Yattee/Models/TVVideoSyncMode.swift new file mode 100644 index 00000000..d1a39e06 --- /dev/null +++ b/Yattee/Models/TVVideoSyncMode.swift @@ -0,0 +1,29 @@ +// +// TVVideoSyncMode.swift +// Yattee +// +// MPV `video-sync` mode override exposed on tvOS for A/V sync debugging. +// + +import SwiftUI + +enum TVVideoSyncMode: String, CaseIterable, Codable { + /// Lock video to display refresh, drop/repeat frames to match. Audio plays at PTS. + /// Current shipping default. + case displayVdrop = "display-vdrop" + /// Lock video to display refresh; resample audio to match. Eliminates A/V drift + /// when the display mode doesn't match content fps, at the cost of tiny audio + /// pitch correction. + case displayResample = "display-resample" + /// libmpv default: video adjusts to audio. Most forgiving when display-mode + /// matching is uncertain. + case audio + + var displayName: LocalizedStringKey { + switch self { + case .displayVdrop: "settings.playback.tvVideoSyncMode.displayVdrop" + case .displayResample: "settings.playback.tvVideoSyncMode.displayResample" + case .audio: "settings.playback.tvVideoSyncMode.audio" + } + } +} diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 0b0dc0ed..a9444911 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -439,8 +439,13 @@ final class MPVClient: @unchecked Sendable { // Use display-vdrop: drops/repeats frames to match display timing // This is lighter weight than display-resample (no interpolation overhead) - // and handles both hardware and software decode gracefully + // and handles both hardware and software decode gracefully. + // On tvOS, the user may override this via the debug Video Sync Mode setting. + #if os(tvOS) + setOptionSync("video-sync", SettingsManager.tvVideoSyncModeSync().rawValue) + #else setOptionSync("video-sync", "display-vdrop") + #endif // Allow frame dropping when decoder can't keep up (essential for software decode) // decoder+vo: drop at decoder level first, then at video output if still behind @@ -480,10 +485,30 @@ final class MPVClient: @unchecked Sendable { // Apply subtitle appearance settings applySubtitleSettings() + // Apply user-configured fixed audio delay on tvOS (in seconds; setting is ms) + #if os(tvOS) + let delaySeconds = SettingsManager.tvAudioDelayMsSync() / 1000.0 + setOptionSync("audio-delay", String(delaySeconds)) + #endif + // Apply user's custom MPV options (these can override defaults) applyCustomOptions() } + /// Update the `audio-delay` property on a running MPV instance. Safe to call + /// while playback is active. Value is in milliseconds; converted to seconds + /// before being sent to MPV. + func updateAudioDelay(milliseconds: Double) { + mpvQueue.async { [weak self] in + guard let self, let mpv = self.mpv, !self.isDestroyed else { return } + let seconds = milliseconds / 1000.0 + let str = String(seconds) + str.withCString { ptr in + _ = mpv_set_property_string(mpv, "audio-delay", ptr) + } + } + } + /// Apply subtitle appearance settings from user preferences. private func applySubtitleSettings() { let subtitleSettings = SettingsManager.subtitleSettingsSync() @@ -981,6 +1006,14 @@ final class MPVClient: @unchecked Sendable { var videoSpeedCorrection: Double? var audioSpeedCorrection: Double? var framedrop: String? + var audioDelay: Double? + var currentVo: String? + var currentAo: String? + var audioDevice: String? + var displayWidth: Int? + var displayHeight: Int? + var displayNames: String? + var decoderFrameDropCount: Int? } /// Fetch all debug properties in a single sync block to avoid multiple lock acquisitions. @@ -1052,6 +1085,14 @@ final class MPVClient: @unchecked Sendable { props.videoSpeedCorrection = getDouble("video-speed-correction") props.audioSpeedCorrection = getDouble("audio-speed-correction") props.framedrop = getString("framedrop") + props.audioDelay = getDouble("audio-delay") + props.currentVo = getString("current-vo") + props.currentAo = getString("current-ao") + props.audioDevice = getString("audio-device") + props.displayWidth = getInt("display-width") + props.displayHeight = getInt("display-height") + props.displayNames = getString("display-names") + props.decoderFrameDropCount = getInt("decoder-frame-drop-count") #endif return props @@ -1281,8 +1322,16 @@ final class MPVClient: @unchecked Sendable { props.videoSpeedCorrection = getDouble("video-speed-correction") props.audioSpeedCorrection = getDouble("audio-speed-correction") props.framedrop = getString("framedrop") + props.audioDelay = getDouble("audio-delay") + props.currentVo = getString("current-vo") + props.currentAo = getString("current-ao") + props.audioDevice = getString("audio-device") + props.displayWidth = getInt("display-width") + props.displayHeight = getInt("display-height") + props.displayNames = getString("display-names") + props.decoderFrameDropCount = getInt("decoder-frame-drop-count") #endif - + continuation.resume(returning: props) } } diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 44bf5439..41463217 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -902,6 +902,12 @@ final class MPVBackend: PlayerBackend { mpvClient?.updateSubtitleSettings() } + /// Push the current tvOS audio-delay setting into the running MPV instance. + /// Caller passes milliseconds; MPVClient converts to seconds. + func updateAudioDelay(milliseconds: Double) { + mpvClient?.updateAudioDelay(milliseconds: milliseconds) + } + /// Get the actual video track dimensions from MPV. /// Returns (width, height) or nil if not available. func getVideoSize() -> (width: Int, height: Int)? { @@ -963,6 +969,14 @@ final class MPVBackend: PlayerBackend { stats.audioSpeedCorrection = props.audioSpeedCorrection stats.framedrop = props.framedrop stats.displayLinkFps = renderView?.displayLinkTargetFPS + stats.audioDelay = props.audioDelay + stats.currentVo = props.currentVo + stats.currentAo = props.currentAo + stats.audioDevice = props.audioDevice + stats.displayWidth = props.displayWidth + stats.displayHeight = props.displayHeight + stats.displayNames = props.displayNames + stats.decoderFrameDropCount = props.decoderFrameDropCount #endif return stats diff --git a/Yattee/Views/Player/MPVDebugOverlay.swift b/Yattee/Views/Player/MPVDebugOverlay.swift index 16b0d2c5..20fed5fd 100644 --- a/Yattee/Views/Player/MPVDebugOverlay.swift +++ b/Yattee/Views/Player/MPVDebugOverlay.swift @@ -47,6 +47,14 @@ struct MPVDebugStats: Equatable { var audioSpeedCorrection: Double? // Audio speed adjustment var framedrop: String? // Frame drop mode (decoder, vo, decoder+vo) var displayLinkFps: Double? // CADisplayLink preferred frame rate + var audioDelay: Double? // Applied audio-delay (seconds) + var currentVo: String? // Active video output driver + var currentAo: String? // Active audio output driver + var audioDevice: String? // Selected audio device id/name + var displayWidth: Int? // tvOS panel width MPV sees + var displayHeight: Int? // tvOS panel height MPV sees + var displayNames: String? // tvOS display name(s) + var decoderFrameDropCount: Int? // Frames dropped at the decoder } /// Debug overlay view for MPV player. @@ -384,6 +392,52 @@ struct MPVDebugOverlay: View { let color: Color = jitterMs > 2 ? .orange : .green statRow("Vsync Jitter", String(format: "%.2f ms", jitterMs), valueColor: color) } + + // Display vs container fps mismatch — non-zero means tvOS display-mode + // matching never engaged for this content, which is the most common + // cause of "MPV says synced but it looks off" on tvOS. + if let displayFps = stats.displayFps, let containerFps = stats.containerFps, + displayFps > 0, containerFps > 0 { + let diff = displayFps - containerFps + let color: Color = abs(diff) > 0.5 ? .orange : .green + statRow("Disp−Cont", String(format: "%+.2f", diff), valueColor: color) + } + + // Estimated output fps (what MPV is actually producing post-VO) + if let vfFps = stats.estimatedVfFps { + statRow("Est VF FPS", String(format: "%.2f", vfFps)) + } + + // Decoder-level drops (vs the VO-level drops shown in the Playback section) + if let decoderDrops = stats.decoderFrameDropCount, decoderDrops > 0 { + statRow("Dec Drops", "\(decoderDrops)", valueColor: .orange) + } + + // Applied audio-delay (sanity check the setting is reaching MPV) + if let delay = stats.audioDelay { + let delayMs = delay * 1000 + let color: Color = abs(delayMs) > 0.5 ? .orange : .green + statRow("Aud Delay", String(format: "%+.0f ms", delayMs), valueColor: color) + } + + // Active VO / AO drivers (confirm libmpv + audiounit at runtime) + if let vo = stats.currentVo { + stackedRow("Current VO", vo) + } + if let ao = stats.currentAo { + stackedRow("Current AO", ao) + } + if let device = stats.audioDevice, !device.isEmpty { + stackedRow("Audio Dev", device) + } + + // Panel info — catches surprising HDMI capability reports + if let w = stats.displayWidth, let h = stats.displayHeight, w > 0, h > 0 { + statRow("Panel", "\(w)×\(h)") + } + if let names = stats.displayNames, !names.isEmpty { + stackedRow("Display", names) + } } #endif diff --git a/Yattee/Views/Settings/AVSyncDiagnosticsView.swift b/Yattee/Views/Settings/AVSyncDiagnosticsView.swift new file mode 100644 index 00000000..d5b91aa2 --- /dev/null +++ b/Yattee/Views/Settings/AVSyncDiagnosticsView.swift @@ -0,0 +1,128 @@ +// +// AVSyncDiagnosticsView.swift +// Yattee +// +// tvOS A/V sync debugging controls, pushed as its own page from Advanced settings. +// - Audio Delay: shifts MPV's `audio-delay` to compensate for fixed HDMI/AVR +// output pipeline latency. Applies live to a running MPV instance. +// - Video Sync Mode: A/B between `display-vdrop` (default), `display-resample`, +// and `audio` modes. Takes effect on next playback start. +// +// The whole screen is tvOS-only because MPV is the tvOS backend and pipeline +// latency only differs there; iOS/macOS use the same MPV code path but +// happen to balance pipeline lag. +// + +#if os(tvOS) +import SwiftUI + +struct AVSyncDiagnosticsView: View { + @Bindable var settings: SettingsManager + @Environment(\.appEnvironment) private var appEnvironment + + private let minDelayMs: Double = -500 + private let maxDelayMs: Double = 500 + private let stepMs: Double = 10 + + var body: some View { + SettingsFormContainer { + SettingsFormSection( + footer: "settings.playback.tvSyncDiagnostics.footer" + ) { + // Current value as a non-interactive row — tvOS Form rows can't + // host multiple focusable controls, so we surface the value here + // and put each control on its own row below. + HStack { + Label( + String(localized: "settings.playback.tvAudioDelay"), + systemImage: "speaker.wave.2.bubble" + ) + Spacer() + Text(String(format: "%+.0f ms", settings.tvAudioDelayMs)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + + // Coarse steps (±100 ms) first so the user can land near the + // right value fast, then fine (±10 ms) to dial it in. + Button { + adjust(by: -100) + } label: { + Label( + String(localized: "settings.playback.tvAudioDelay.minus100"), + systemImage: "gobackward.minus" + ) + } + .disabled(settings.tvAudioDelayMs <= minDelayMs) + + Button { + adjust(by: -stepMs) + } label: { + Label( + String(localized: "settings.playback.tvAudioDelay.minus10"), + systemImage: "minus" + ) + } + .disabled(settings.tvAudioDelayMs <= minDelayMs) + + Button { + adjust(by: stepMs) + } label: { + Label( + String(localized: "settings.playback.tvAudioDelay.plus10"), + systemImage: "plus" + ) + } + .disabled(settings.tvAudioDelayMs >= maxDelayMs) + + Button { + adjust(by: 100) + } label: { + Label( + String(localized: "settings.playback.tvAudioDelay.plus100"), + systemImage: "goforward.plus" + ) + } + .disabled(settings.tvAudioDelayMs >= maxDelayMs) + + Button(role: .destructive) { + set(to: 0) + } label: { + Label( + String(localized: "settings.playback.tvAudioDelay.reset"), + systemImage: "arrow.counterclockwise" + ) + } + .disabled(settings.tvAudioDelayMs == 0) + + PlatformMenuPicker( + String(localized: "settings.playback.tvVideoSyncMode"), + selection: $settings.tvVideoSyncMode + ) { + ForEach(TVVideoSyncMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + } + } + } + + private func adjust(by delta: Double) { + let next = (settings.tvAudioDelayMs + delta).clamped(to: minDelayMs...maxDelayMs) + set(to: next) + } + + private func set(to value: Double) { + settings.tvAudioDelayMs = value + if let mpvBackend = appEnvironment?.playerService.currentBackend as? MPVBackend { + mpvBackend.updateAudioDelay(milliseconds: value) + } + } +} + +private extension Comparable { + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } +} +#endif diff --git a/Yattee/Views/Settings/AdvancedSettingsView.swift b/Yattee/Views/Settings/AdvancedSettingsView.swift index 5f219d49..bf1abd22 100644 --- a/Yattee/Views/Settings/AdvancedSettingsView.swift +++ b/Yattee/Views/Settings/AdvancedSettingsView.swift @@ -32,6 +32,9 @@ struct AdvancedSettingsView: View { #endif streamDetailsSection mpvSection + #if os(tvOS) + avSyncSection + #endif settingsSection #if !os(tvOS) downloadsStorageSection @@ -230,6 +233,26 @@ struct AdvancedSettingsView: View { } } + #if os(tvOS) + @ViewBuilder + private var avSyncSection: some View { + if let settings = appEnvironment?.settingsManager { + SettingsFormSection { + NavigationLink { + TVSidebarDetailContainer( + systemImage: "wave.3.right", + title: String(localized: "settings.playback.tvSyncDiagnostics.header") + ) { + AVSyncDiagnosticsView(settings: settings) + } + } label: { + Label(String(localized: "settings.playback.tvSyncDiagnostics.row"), systemImage: "wave.3.right") + } + } + } + } + #endif + private static let mpvBufferOptions: [Double] = [1.0, 2.0, 3.0, 4.0, 5.0] private func formatBufferOption(_ seconds: Double) -> String {