Add tvOS A/V sync diagnostics on its own settings page

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.
This commit is contained in:
Arkadiusz Fal
2026-05-14 09:43:25 +02:00
parent 579df26284
commit c168fbae02
11 changed files with 494 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
{
"id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1",
"targets" : [
{
"id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691",
"name" : "Yattee"
}
]
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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" : {

View File

@@ -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"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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("DispCont", 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

View File

@@ -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>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
#endif

View File

@@ -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 {