mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 21:43:41 +00:00
b54044cbc5
HLS: try matching the set resolution. This works okay with AVPlayer. With MPV it is hit and miss, most of the time MPV targets the highest available bitrate, instead of the set bitrate. AVPlayer now supports higher resolution up to 1080p60.
405 lines
12 KiB
Swift
405 lines
12 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: 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) {
|
|
Text("Formats can be reordered and will be selected in this order.")
|
|
.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 > .hd1080p60
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|