mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 06:42:01 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
128
Yattee/Views/Settings/AVSyncDiagnosticsView.swift
Normal file
128
Yattee/Views/Settings/AVSyncDiagnosticsView.swift
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user