mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 05:42:04 +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:
9
Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json
Normal file
9
Yattee.xcodeproj/xcshareddata/xcodecloud/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1",
|
||||
"targets" : [
|
||||
{
|
||||
"id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691",
|
||||
"name" : "Yattee"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
29
Yattee/Models/TVVideoSyncMode.swift
Normal file
29
Yattee/Models/TVVideoSyncMode.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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