mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 22:32: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:
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