mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +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 zoomTransitionsEnabled
|
||||||
case tvMatchDisplayFrameRate
|
case tvMatchDisplayFrameRate
|
||||||
case tvMatchDisplayDynamicRange
|
case tvMatchDisplayDynamicRange
|
||||||
|
case tvAudioDelayMs
|
||||||
|
case tvVideoSyncMode
|
||||||
|
|
||||||
// Details panel
|
// Details panel
|
||||||
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
|
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
|
/// 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
|
/// playing video's dynamic range (SDR / HDR10 / HLG). Has no effect unless the
|
||||||
/// user also enables "Match Content → Dynamic Range" in tvOS Settings.
|
/// user also enables "Match Content → Dynamic Range" in tvOS Settings.
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ final class SettingsManager {
|
|||||||
var _zoomTransitionsEnabled: Bool?
|
var _zoomTransitionsEnabled: Bool?
|
||||||
var _tvMatchDisplayFrameRate: Bool?
|
var _tvMatchDisplayFrameRate: Bool?
|
||||||
var _tvMatchDisplayDynamicRange: Bool?
|
var _tvMatchDisplayDynamicRange: Bool?
|
||||||
|
var _tvAudioDelayMs: Double?
|
||||||
|
var _tvVideoSyncMode: TVVideoSyncMode?
|
||||||
|
|
||||||
// Details panel settings
|
// Details panel settings
|
||||||
var _floatingDetailsPanelSide: FloatingPanelSide?
|
var _floatingDetailsPanelSide: FloatingPanelSide?
|
||||||
@@ -496,6 +498,8 @@ final class SettingsManager {
|
|||||||
_zoomTransitionsEnabled = nil
|
_zoomTransitionsEnabled = nil
|
||||||
_tvMatchDisplayFrameRate = nil
|
_tvMatchDisplayFrameRate = nil
|
||||||
_tvMatchDisplayDynamicRange = nil
|
_tvMatchDisplayDynamicRange = nil
|
||||||
|
_tvAudioDelayMs = nil
|
||||||
|
_tvVideoSyncMode = nil
|
||||||
_floatingDetailsPanelSide = nil
|
_floatingDetailsPanelSide = nil
|
||||||
_floatingDetailsPanelWidth = nil
|
_floatingDetailsPanelWidth = nil
|
||||||
_landscapeDetailsPanelVisible = 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" : {
|
"settings.playback.tvOSMenuButtonClosesVideo" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"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
|
// Use display-vdrop: drops/repeats frames to match display timing
|
||||||
// This is lighter weight than display-resample (no interpolation overhead)
|
// 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")
|
setOptionSync("video-sync", "display-vdrop")
|
||||||
|
#endif
|
||||||
|
|
||||||
// Allow frame dropping when decoder can't keep up (essential for software decode)
|
// 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
|
// 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
|
// Apply subtitle appearance settings
|
||||||
applySubtitleSettings()
|
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)
|
// Apply user's custom MPV options (these can override defaults)
|
||||||
applyCustomOptions()
|
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.
|
/// Apply subtitle appearance settings from user preferences.
|
||||||
private func applySubtitleSettings() {
|
private func applySubtitleSettings() {
|
||||||
let subtitleSettings = SettingsManager.subtitleSettingsSync()
|
let subtitleSettings = SettingsManager.subtitleSettingsSync()
|
||||||
@@ -981,6 +1006,14 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
var videoSpeedCorrection: Double?
|
var videoSpeedCorrection: Double?
|
||||||
var audioSpeedCorrection: Double?
|
var audioSpeedCorrection: Double?
|
||||||
var framedrop: String?
|
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.
|
/// 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.videoSpeedCorrection = getDouble("video-speed-correction")
|
||||||
props.audioSpeedCorrection = getDouble("audio-speed-correction")
|
props.audioSpeedCorrection = getDouble("audio-speed-correction")
|
||||||
props.framedrop = getString("framedrop")
|
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
|
#endif
|
||||||
|
|
||||||
return props
|
return props
|
||||||
@@ -1281,8 +1322,16 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
props.videoSpeedCorrection = getDouble("video-speed-correction")
|
props.videoSpeedCorrection = getDouble("video-speed-correction")
|
||||||
props.audioSpeedCorrection = getDouble("audio-speed-correction")
|
props.audioSpeedCorrection = getDouble("audio-speed-correction")
|
||||||
props.framedrop = getString("framedrop")
|
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
|
#endif
|
||||||
|
|
||||||
continuation.resume(returning: props)
|
continuation.resume(returning: props)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -902,6 +902,12 @@ final class MPVBackend: PlayerBackend {
|
|||||||
mpvClient?.updateSubtitleSettings()
|
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.
|
/// Get the actual video track dimensions from MPV.
|
||||||
/// Returns (width, height) or nil if not available.
|
/// Returns (width, height) or nil if not available.
|
||||||
func getVideoSize() -> (width: Int, height: Int)? {
|
func getVideoSize() -> (width: Int, height: Int)? {
|
||||||
@@ -963,6 +969,14 @@ final class MPVBackend: PlayerBackend {
|
|||||||
stats.audioSpeedCorrection = props.audioSpeedCorrection
|
stats.audioSpeedCorrection = props.audioSpeedCorrection
|
||||||
stats.framedrop = props.framedrop
|
stats.framedrop = props.framedrop
|
||||||
stats.displayLinkFps = renderView?.displayLinkTargetFPS
|
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
|
#endif
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ struct MPVDebugStats: Equatable {
|
|||||||
var audioSpeedCorrection: Double? // Audio speed adjustment
|
var audioSpeedCorrection: Double? // Audio speed adjustment
|
||||||
var framedrop: String? // Frame drop mode (decoder, vo, decoder+vo)
|
var framedrop: String? // Frame drop mode (decoder, vo, decoder+vo)
|
||||||
var displayLinkFps: Double? // CADisplayLink preferred frame rate
|
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.
|
/// Debug overlay view for MPV player.
|
||||||
@@ -384,6 +392,52 @@ struct MPVDebugOverlay: View {
|
|||||||
let color: Color = jitterMs > 2 ? .orange : .green
|
let color: Color = jitterMs > 2 ? .orange : .green
|
||||||
statRow("Vsync Jitter", String(format: "%.2f ms", jitterMs), valueColor: color)
|
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
|
#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
|
#endif
|
||||||
streamDetailsSection
|
streamDetailsSection
|
||||||
mpvSection
|
mpvSection
|
||||||
|
#if os(tvOS)
|
||||||
|
avSyncSection
|
||||||
|
#endif
|
||||||
settingsSection
|
settingsSection
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
downloadsStorageSection
|
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 static let mpvBufferOptions: [Double] = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||||
|
|
||||||
private func formatBufferOption(_ seconds: Double) -> String {
|
private func formatBufferOption(_ seconds: Double) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user