Files
yattee/Yattee/Views/Settings/MPVOptionsSettingsView.swift
Arkadiusz Fal afa73e6bcb Prefer avfoundation AO on macOS 27 to avoid mpv hotplug listener crash
On macOS 27 beta, mpv's coreaudio AO fails to initialize (channel layout
set rejected with paramErr -50). The existing ao=coreaudio,avfoundation
fallback restored audio, but exposed an mpv bug: init() registers a HAL
hotplug listener (AudioObjectAddPropertyListener on the system object,
with the raw ao pointer as user data) before the step that fails, and
the coreaudio_error path never unregisters it. mpv then frees the ao
and falls back to avfoundation, leaving a dangling listener per
playback start. The next audio device change (AirPods connect,
sleep/wake device republish) invokes the listener with the freed
pointer and crashes with a use-after-free SIGSEGV on the
HALC_ProxyNotification Call Listener Queue (observed in build 262;
faulting address was reused "texture_" shader string memory).

Gate the AO order by OS version: on macOS 27+ use
avfoundation,coreaudio so the failing coreaudio init never runs; older
macOS keeps the native coreaudio first with avfoundation as fallback.
Update the read-only defaults mirror in MPV options settings to match.
2026-07-07 23:51:34 +02:00

576 lines
19 KiB
Swift

//
// MPVOptionsSettingsView.swift
// Yattee
//
// Settings view for displaying and managing MPV options.
//
import SwiftUI
struct MPVOptionsSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var showingAddSheet = false
@State private var editingOption: (name: String, value: String)?
var body: some View {
SettingsFormContainer {
if let settings = appEnvironment?.settingsManager {
#if os(tvOS)
CustomOptionsSection(
settings: settings,
showingAddSheet: $showingAddSheet,
editingOption: $editingOption
)
DefaultOptionsSection()
#else
DefaultOptionsSection()
CustomOptionsSection(
settings: settings,
showingAddSheet: $showingAddSheet,
editingOption: $editingOption
)
#endif
}
}
.navigationTitle(String(localized: "settings.mpvOptions.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.sheet(isPresented: $showingAddSheet) {
if let settings = appEnvironment?.settingsManager {
AddMPVOptionSheet(settings: settings)
}
}
.sheet(item: Binding(
get: { editingOption.map { EditableOption(name: $0.name, value: $0.value) } },
set: { editingOption = $0.map { ($0.name, $0.value) } }
)) { option in
if let settings = appEnvironment?.settingsManager {
EditMPVOptionSheet(
settings: settings,
originalName: option.name,
initialValue: option.value
)
}
}
}
}
// MARK: - Identifiable wrapper for editing
private struct EditableOption: Identifiable {
let name: String
let value: String
var id: String { name }
}
// MARK: - Default Options Section
private struct DefaultOptionsSection: View {
var body: some View {
#if os(tvOS)
SettingsFormSection(footer: "settings.mpvOptions.defaultOptions.footer") {
Text(String(localized: "settings.mpvOptions.defaultOptions"))
.font(.headline)
ForEach(Self.defaultOptions, id: \.name) { option in
LabeledContent(option.name) {
Text(option.value)
.foregroundStyle(.secondary)
}
.focusable()
}
}
#else
SettingsFormSection("settings.mpvOptions.defaultOptions", footer: "settings.mpvOptions.defaultOptions.footer") {
ForEach(Self.defaultOptions, id: \.name) { option in
HStack(alignment: .firstTextBaseline) {
Text(option.name)
.monospaced()
Spacer()
Text(option.value)
.monospaced()
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
}
}
#endif
}
/// Default MPV options from MPVClient.configureDefaultOptions()
private static let defaultOptions: [(name: String, value: String)] = {
var options: [(name: String, value: String)] = []
options.append(("vo", "libmpv"))
#if targetEnvironment(simulator)
options.append(("hwdec", "no"))
options.append(("sw-fast", "yes"))
#else
options.append(("hwdec", "videotoolbox-copy"))
options.append(("hwdec-codecs", "h264,hevc,mpeg1video,mpeg2video,mpeg4,vp9,av1,prores"))
#endif
options.append(("keep-open", "yes"))
options.append(("pause", "yes"))
options.append(("target-prim", "bt.709"))
options.append(("target-trc", "srgb"))
options.append(("video-sync", "display-vdrop"))
options.append(("framedrop", "decoder+vo"))
options.append(("audio-client-name", "Yattee"))
#if os(iOS) || os(tvOS)
options.append(("ao", "audiounit"))
#else
if ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 27 {
options.append(("ao", "avfoundation,coreaudio"))
} else {
options.append(("ao", "coreaudio,avfoundation"))
}
#endif
options.append(("cache", "yes"))
options.append(("demuxer-max-bytes", "50MiB"))
options.append(("demuxer-max-back-bytes", "25MiB"))
return options
}()
}
// MARK: - Custom Options Section
private struct CustomOptionsSection: View {
@Bindable var settings: SettingsManager
@Binding var showingAddSheet: Bool
@Binding var editingOption: (name: String, value: String)?
var body: some View {
SettingsFormSection("settings.mpvOptions.customOptions", footer: "settings.mpvOptions.customOptions.footer") {
let sortedOptions = settings.customMPVOptions.sorted { $0.key < $1.key }
if sortedOptions.isEmpty {
Text(String(localized: "settings.mpvOptions.customOptions.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(sortedOptions, id: \.key) { name, value in
Button {
editingOption = (name, value)
} label: {
HStack {
Text(name)
.foregroundStyle(.primary)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.plain)
#endif
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
removeOption(named: name)
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
#endif
.contextMenu {
Button(role: .destructive) {
removeOption(named: name)
} label: {
Label(String(localized: "common.delete"), systemImage: "trash")
}
}
}
}
HStack {
Button {
showingAddSheet = true
} label: {
Label(String(localized: "settings.mpvOptions.addOption"), systemImage: "plus")
}
Spacer()
}
}
}
private func removeOption(named name: String) {
var options = settings.customMPVOptions
options.removeValue(forKey: name)
settings.customMPVOptions = options
}
}
// MARK: - Add MPV Option Sheet
private struct AddMPVOptionSheet: View {
@Environment(\.dismiss) private var dismiss
@Bindable var settings: SettingsManager
@State private var optionName = ""
@State private var optionValue = ""
private var trimmedName: String {
optionName.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedValue: String {
optionValue.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool { !trimmedName.isEmpty && !trimmedValue.isEmpty }
private func save() {
if canSave {
var options = settings.customMPVOptions
options[trimmedName] = trimmedValue
settings.customMPVOptions = options
}
dismiss()
}
var body: some View {
#if os(macOS)
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: "settings.mpvOptions.addOption.title"))
.font(.headline)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
Text(String(localized: "settings.mpvOptions.optionName"))
.gridColumnAlignment(.trailing)
TextField("", text: $optionName)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
GridRow {
Text(String(localized: "settings.mpvOptions.optionValue"))
.gridColumnAlignment(.trailing)
TextField("", text: $optionValue)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
}
Text(String(localized: "settings.mpvOptions.addOption.footer"))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button(String(localized: "common.cancel")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button(String(localized: "common.add")) {
save()
}
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
}
}
.padding(20)
.frame(minWidth: 420)
#elseif os(tvOS)
VStack(spacing: 30) {
Text(String(localized: "settings.mpvOptions.addOption.title"))
.font(.title2)
.fontWeight(.semibold)
VStack(spacing: 20) {
TextField(
String(localized: "settings.mpvOptions.optionName"),
text: $optionName
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField(
String(localized: "settings.mpvOptions.optionValue"),
text: $optionValue
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Button {
save()
} label: {
Text(String(localized: "common.add"))
.frame(maxWidth: .infinity)
}
.disabled(!canSave)
Text(String(localized: "settings.mpvOptions.addOption.footer"))
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(60)
.frame(maxWidth: 900)
#else
NavigationStack {
Form {
Section {
TextField(
String(localized: "settings.mpvOptions.optionName"),
text: $optionName
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField(
String(localized: "settings.mpvOptions.optionValue"),
text: $optionValue
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} footer: {
Text(String(localized: "settings.mpvOptions.addOption.footer"))
}
}
.navigationTitle(String(localized: "settings.mpvOptions.addOption.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "common.add")) {
save()
}
.disabled(!canSave)
}
}
}
#endif
}
}
// MARK: - Edit MPV Option Sheet
private struct EditMPVOptionSheet: View {
@Environment(\.dismiss) private var dismiss
@Bindable var settings: SettingsManager
let originalName: String
let initialValue: String
@State private var optionValue = ""
@State private var showingDeleteConfirmation = false
private var trimmedValue: String {
optionValue.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool { !trimmedValue.isEmpty }
private func save() {
if canSave {
var options = settings.customMPVOptions
options[originalName] = trimmedValue
settings.customMPVOptions = options
}
dismiss()
}
private func delete() {
var options = settings.customMPVOptions
options.removeValue(forKey: originalName)
settings.customMPVOptions = options
dismiss()
}
var body: some View {
#if os(macOS)
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: "settings.mpvOptions.editOption.title"))
.font(.headline)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
Text(String(localized: "settings.mpvOptions.optionName"))
.gridColumnAlignment(.trailing)
Text(originalName)
.monospaced()
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
Text(String(localized: "settings.mpvOptions.optionValue"))
.gridColumnAlignment(.trailing)
TextField("", text: $optionValue)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
}
HStack {
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "settings.mpvOptions.deleteOption"), systemImage: "trash")
}
Spacer()
Button(String(localized: "common.cancel")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button(String(localized: "common.save")) {
save()
}
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
}
}
.padding(20)
.frame(minWidth: 420)
.confirmationDialog(
String(localized: "settings.mpvOptions.deleteOption.confirmation"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
delete()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.onAppear {
optionValue = initialValue
}
#elseif os(tvOS)
VStack(spacing: 30) {
Text(String(localized: "settings.mpvOptions.editOption.title"))
.font(.title2)
.fontWeight(.semibold)
VStack(spacing: 20) {
HStack {
Text(String(localized: "settings.mpvOptions.optionName"))
Spacer()
Text(originalName)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
TextField(
String(localized: "settings.mpvOptions.optionValue"),
text: $optionValue
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Button {
save()
} label: {
Text(String(localized: "common.save"))
.frame(maxWidth: .infinity)
}
.disabled(!canSave)
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "settings.mpvOptions.deleteOption"), systemImage: "trash")
.frame(maxWidth: .infinity)
}
}
.padding(60)
.frame(maxWidth: 900)
.confirmationDialog(
String(localized: "settings.mpvOptions.deleteOption.confirmation"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
delete()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.onAppear {
optionValue = initialValue
}
#else
NavigationStack {
Form {
Section {
LabeledContent(String(localized: "settings.mpvOptions.optionName")) {
Text(originalName)
}
TextField(
String(localized: "settings.mpvOptions.optionValue"),
text: $optionValue
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "settings.mpvOptions.deleteOption"), systemImage: "trash")
}
}
}
.navigationTitle(String(localized: "settings.mpvOptions.editOption.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "common.save")) {
save()
}
.disabled(!canSave)
}
}
.confirmationDialog(
String(localized: "settings.mpvOptions.deleteOption.confirmation"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
delete()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.presentationCompactAdaptation(.sheet)
}
.onAppear {
optionValue = initialValue
}
#endif
}
}
// MARK: - Preview
#Preview {
NavigationStack {
MPVOptionsSettingsView()
}
.appEnvironment(.preview)
}