mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 21:43:41 +00:00
94577332a1
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
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)
|
|
}
|
|
}
|