import Defaults import SwiftUI struct FormatState: Equatable { let format: QualityProfile.Format var isActive: Bool } struct QualityProfileForm: View { @Binding var qualityProfileID: QualityProfile.ID? @Environment(\.colorScheme) private var colorScheme @Environment(\.presentationMode) private var presentationMode @Environment(\.navigationStyle) private var navigationStyle @State private var valid = false @State private var initialized = false @State private var name = "" @State private var backend = PlayerBackendType.mpv @State private var resolution = ResolutionSetting.hd1080p60 @State private var formats = [QualityProfile.Format]() @State private var orderedFormats: [FormatState] = [] @Default(.qualityProfiles) private var qualityProfiles var qualityProfile: QualityProfile! { if let id = qualityProfileID { return QualityProfilesModel.shared.find(id) } return nil } // swiftlint:disable trailing_closure var body: some View { VStack { Group { header form footer } .frame(maxWidth: 1000) } #if os(tvOS) .padding(20) #endif .onAppear(perform: initializeForm) .onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() }) .onChange(of: name, perform: { _ in validate() }) .onChange(of: resolution, perform: { _ in validate() }) .onChange(of: orderedFormats, perform: { _ in validate() }) #if os(iOS) .padding(.vertical) #elseif os(tvOS) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .background(Color.background(scheme: colorScheme)) #else .frame(width: 400, height: 400) .padding(.vertical, 10) #endif } // swiftlint:enable trailing_closure var header: some View { HStack { Text(editing ? "Edit Quality Profile" : "Add Quality Profile") .font(.title2.bold()) Spacer() Button("Cancel") { presentationMode.wrappedValue.dismiss() } #if !os(tvOS) .keyboardShortcut(.cancelAction) #endif } .padding(.horizontal) } var form: some View { #if os(tvOS) ScrollView { VStack { formFields } .padding(.horizontal, 20) } #else Form { formFields #if os(macOS) .padding(.horizontal) #endif } #endif } var formFields: some View { Group { Section { HStack { nameHeader TextField("Name", text: $name, onCommit: validate) .labelsHidden() } #if os(tvOS) Section(header: Text("Resolution")) { qualityButton } Section(header: Text("Backend")) { backendPicker } #else backendPicker qualityPicker #endif } Section(header: Text("Preferred Formats"), footer: formatsFooter) { formatsPicker } } #if os(tvOS) .frame(maxWidth: .infinity, alignment: .leading) #endif } @ViewBuilder var nameHeader: some View { #if os(macOS) Text("Name") #endif } var formatsFooter: some View { VStack(alignment: .leading) { if #available(iOS 16.0, *) { Text("Formats can be reordered and will be selected in this order.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } else if #available(iOS 14.0, *) { Text("Formats will be selected in the order they are listed.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } else { Text("Formats will be selected in the order they are listed.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.top) Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.top, 0.1) } .padding(.top, 2) } @ViewBuilder var qualityPicker: some View { let picker = Picker("Resolution", selection: $resolution) { ForEach(availableResolutions, id: \.self) { resolution in Text(resolution.description).tag(resolution) } } .modifier(SettingsPickerModifier()) #if os(iOS) HStack { Text("Resolution") Spacer() Menu { picker } label: { Text(resolution.description) .frame(minWidth: 120, alignment: .trailing) } .transaction { t in t.animation = .none } } #else picker #endif } #if os(tvOS) var qualityButton: some View { Button(resolution.description) { resolution = resolution.next() } .contextMenu { ForEach(availableResolutions, id: \.self) { resolution in Button(resolution.description) { self.resolution = resolution } } } } #endif var availableResolutions: [ResolutionSetting] { ResolutionSetting.allCases.filter { !isResolutionDisabled($0) } } @ViewBuilder var backendPicker: some View { let picker = Picker("Backend", selection: $backend) { ForEach(PlayerBackendType.allCases, id: \.self) { backend in Text(backend.label).tag(backend) } } .modifier(SettingsPickerModifier()) #if os(iOS) HStack { Text("Backend") Spacer() Menu { picker } label: { Text(backend.label) .frame(minWidth: 120, alignment: .trailing) } .transaction { t in t.animation = .none } } #else picker #endif } var filteredFormatList: some View { ForEach(Array(orderedFormats.enumerated()), id: \.element.format) { idx, element in let format = element.format MultiselectRow( title: format.description, selected: element.isActive ) { value in orderedFormats[idx].isActive = value } } .onMove { source, destination in orderedFormats.move(fromOffsets: source, toOffset: destination) validate() } } @ViewBuilder var formatsPicker: some View { #if os(macOS) let list = filteredFormatList Group { if #available(macOS 12.0, *) { list .listStyle(.inset(alternatesRowBackgrounds: true)) } else { list .listStyle(.inset) } } Spacer() #else filteredFormatList #endif } func isFormatSelected(_ format: QualityProfile.Format) -> Bool { return orderedFormats.first { $0.format == format }?.isActive ?? false } func toggleFormat(_ format: QualityProfile.Format, value: Bool) { if let index = orderedFormats.firstIndex(where: { $0.format == format }) { orderedFormats[index].isActive = value } validate() // Check validity after a toggle operation } var footer: some View { HStack { Spacer() Button("Save", action: submitForm) .disabled(!valid) #if !os(tvOS) .keyboardShortcut(.defaultAction) #endif } .frame(minHeight: 35) #if os(tvOS) .padding(.top, 30) #endif .padding(.horizontal) } var editing: Bool { !qualityProfile.isNil } func isFormatDisabled(_ format: QualityProfile.Format) -> Bool { guard backend == .appleAVPlayer else { return false } let avPlayerFormats = [QualityProfile.Format.hls, .stream, .mp4] return !avPlayerFormats.contains(format) } func updateActiveFormats() { for (index, format) in orderedFormats.enumerated() where isFormatDisabled(format.format) { orderedFormats[index].isActive = false } } func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool { guard backend == .appleAVPlayer else { return false } return resolution.value > .hd720p30 } func initializeForm() { if editing { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.name = qualityProfile.name ?? "" self.backend = qualityProfile.backend self.resolution = qualityProfile.resolution self.orderedFormats = qualityProfile.order.map { order in let format = QualityProfile.Format.allCases[order] let isActive = qualityProfile.formats.contains(format) return FormatState(format: format, isActive: isActive) } self.initialized = true } } else { name = "" backend = .mpv resolution = .hd720p60 orderedFormats = QualityProfile.Format.allCases.map { FormatState(format: $0, isActive: true) } initialized = true } validate() } func backendChanged(_: PlayerBackendType) { let defaultFormats = QualityProfile.Format.allCases.map { FormatState(format: $0, isActive: true) } if backend == .appleAVPlayer { orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) } } else { orderedFormats = defaultFormats } if isResolutionDisabled(resolution), let newResolution = availableResolutions.first { resolution = newResolution } } func validate() { if !initialized { valid = false } else if editing { let savedOrderFormats = qualityProfile.order.map { order in let format = QualityProfile.Format.allCases[order] let isActive = qualityProfile.formats.contains(format) return FormatState(format: format, isActive: isActive) } valid = name != qualityProfile.name || backend != qualityProfile.backend || resolution != qualityProfile.resolution || orderedFormats != savedOrderFormats } else { valid = true } } func submitForm() { guard valid else { return } let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format } let formProfile = QualityProfile( id: qualityProfile?.id ?? UUID().uuidString, name: name, backend: backend, resolution: resolution, formats: activeFormats, order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! } ) if editing { QualityProfilesModel.shared.update(qualityProfile, formProfile) } else { let wasEmpty = qualityProfiles.isEmpty QualityProfilesModel.shared.add(formProfile) if wasEmpty { QualityProfilesModel.shared.applyToAll(formProfile) } } presentationMode.wrappedValue.dismiss() } } struct QualityProfileForm_Previews: PreviewProvider { static var previews: some View { QualityProfileForm(qualityProfileID: .constant(QualityProfile.defaultProfile.id)) .environment(\.navigationStyle, .tab) } }