mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-31 20:52:04 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			415 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			415 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
| 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: 450)
 | |
|             .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(\.isActive).map(\.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)
 | |
|     }
 | |
| }
 | 
