yattee/Shared/Settings/QualityProfileForm.swift
Toni Förster 9cb0325503
more robust resolution handling
Currently, we have a hard-coded list of resolutions. Since Invidious reports the actual resolution of a stream and does not hard-code them to a fixed value anymore, resolutions that are not in the list won’t be handled, and the stream cannot be played back.

Instead of hard-coding even more resolutions (and inadvertently might not cover all), we revert the list back to a finite set of resolutions, the users can select from. All other resolutions are handled dynamically and compared to the existing set of defined resolutions when selecting the best stream for playback.

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
2024-09-09 12:59:39 +02:00

417 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 = [.stream, QualityProfile.Format.hls]
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 }
let hd720p30 = Stream.Resolution.predefined(.hd720p30)
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)
}
}