mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Quality profiles
This commit is contained in:
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
struct PlayerSettings: View {
|
||||
@Default(.instances) private var instances
|
||||
@Default(.playerInstanceID) private var playerInstanceID
|
||||
@Default(.quality) private var quality
|
||||
|
||||
@Default(.playerSidebar) private var playerSidebar
|
||||
@Default(.showHistoryInPlayer) private var showHistory
|
||||
@@ -59,7 +58,6 @@ struct PlayerSettings: View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Playback")) {
|
||||
sourcePicker
|
||||
qualityPicker
|
||||
pauseOnHidingPlayerToggle
|
||||
#if !os(macOS)
|
||||
pauseOnEnteringBackgroundToogle
|
||||
@@ -107,7 +105,7 @@ struct PlayerSettings: View {
|
||||
|
||||
private var sourcePicker: some View {
|
||||
Picker("Source", selection: $playerInstanceID) {
|
||||
Text("Best available stream").tag(String?.none)
|
||||
Text("Account Instance").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
@@ -135,15 +133,6 @@ struct PlayerSettings: View {
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var qualityPicker: some View {
|
||||
Picker("Quality", selection: $quality) {
|
||||
ForEach(ResolutionSetting.allCases, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
private var sidebarPicker: some View {
|
||||
Picker("Sidebar", selection: $playerSidebar) {
|
||||
#if os(macOS)
|
||||
|
312
Shared/Settings/QualityProfileForm.swift
Normal file
312
Shared/Settings/QualityProfileForm.swift
Normal file
@@ -0,0 +1,312 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QualityProfileForm: View {
|
||||
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 name = ""
|
||||
@State private var backend = PlayerBackendType.mpv
|
||||
@State private var resolution = ResolutionSetting.best
|
||||
@State private var formats = [QualityProfile.Format]()
|
||||
|
||||
var qualityProfile: QualityProfile! {
|
||||
if let id = qualityProfileID {
|
||||
return QualityProfilesModel.shared.find(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack {
|
||||
Group {
|
||||
header
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
EmptyView()
|
||||
|
||||
form
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarTitle(Text("Back"))
|
||||
.edgesIgnoringSafeArea([.top, .bottom])
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#else
|
||||
form
|
||||
#endif
|
||||
footer
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(20)
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: backend, perform: backendChanged)
|
||||
.onChange(of: formats) { _ 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
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack(alignment: .center) {
|
||||
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)
|
||||
Form {
|
||||
formFields
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
formFields
|
||||
#endif
|
||||
}
|
||||
|
||||
var formFields: some View {
|
||||
Group {
|
||||
Section {
|
||||
HStack {
|
||||
nameHeader
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.labelsHidden()
|
||||
}
|
||||
#if os(tvOS)
|
||||
Section(header: Text("Resolution")) {
|
||||
qualityButton
|
||||
}
|
||||
#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")
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
var formatsFooter: some View {
|
||||
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
var qualityPicker: some View {
|
||||
Picker("Resolution", selection: $resolution) {
|
||||
ForEach(availableResolutions, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
#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) }
|
||||
}
|
||||
|
||||
var backendPicker: some View {
|
||||
Picker("Backend", selection: $backend) {
|
||||
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
|
||||
Text(backend.label).tag(backend)
|
||||
}
|
||||
}
|
||||
.modifier(SettingsPickerModifier())
|
||||
}
|
||||
|
||||
@ViewBuilder var formatsPicker: some View {
|
||||
#if os(macOS)
|
||||
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
|
||||
MultiselectRow(
|
||||
title: format.description,
|
||||
selected: isFormatSelected(format),
|
||||
disabled: isFormatDisabled(format)
|
||||
) { value in
|
||||
toggleFormat(format, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
|
||||
(editing && formats.isEmpty ? qualityProfile.formats : formats).contains(format)
|
||||
}
|
||||
|
||||
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
|
||||
if let index = formats.firstIndex(where: { $0 == format }), !value {
|
||||
formats.remove(at: index)
|
||||
} else if value {
|
||||
formats.append(format)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
return !avPlayerFormats.contains(format)
|
||||
}
|
||||
|
||||
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
|
||||
guard backend == .appleAVPlayer else { return false }
|
||||
|
||||
return resolution != .best && resolution.value.height > 720
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
guard editing else {
|
||||
validate()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.name = qualityProfile.name ?? ""
|
||||
self.backend = qualityProfile.backend
|
||||
self.resolution = qualityProfile.resolution
|
||||
self.formats = .init(qualityProfile.formats)
|
||||
}
|
||||
|
||||
validate()
|
||||
}
|
||||
|
||||
func backendChanged(_: PlayerBackendType) {
|
||||
formats.filter { isFormatDisabled($0) }.forEach { format in
|
||||
if let index = formats.firstIndex(where: { $0 == format }) {
|
||||
formats.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
if let newResolution = availableResolutions.first {
|
||||
resolution = newResolution
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
valid = !formats.isEmpty
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else { return }
|
||||
|
||||
formats = formats.unique()
|
||||
|
||||
let formProfile = QualityProfile(
|
||||
id: qualityProfile?.id ?? UUID().uuidString,
|
||||
name: name,
|
||||
backend: backend,
|
||||
resolution: resolution,
|
||||
formats: Array(formats)
|
||||
)
|
||||
|
||||
if editing {
|
||||
QualityProfilesModel.shared.update(qualityProfile, formProfile)
|
||||
} else {
|
||||
QualityProfilesModel.shared.add(formProfile)
|
||||
}
|
||||
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityProfileForm_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
QualityProfileForm(qualityProfileID: QualityProfile.defaultProfile.id)
|
||||
}
|
||||
}
|
184
Shared/Settings/QualitySettings.swift
Normal file
184
Shared/Settings/QualitySettings.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct QualitySettings: View {
|
||||
@State private var presentingProfileForm = false
|
||||
@State private var editedProfile: QualityProfile?
|
||||
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
|
||||
@Default(.batteryCellularProfile) private var batteryCellularProfile
|
||||
@Default(.batteryNonCellularProfile) private var batteryNonCellularProfile
|
||||
@Default(.chargingCellularProfile) private var chargingCellularProfile
|
||||
@Default(.chargingNonCellularProfile) private var chargingNonCellularProfile
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if os(macOS)
|
||||
sections
|
||||
|
||||
Spacer()
|
||||
#else
|
||||
List {
|
||||
sections
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $presentingProfileForm) {
|
||||
QualityProfileForm(qualityProfileID: editedProfile?.id)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.navigationTitle("Quality")
|
||||
}
|
||||
|
||||
var sections: some View {
|
||||
Group {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
Section(header: Text("Default Profile")) {
|
||||
Text("\(QualityProfilesModel.shared.tvOSProfile?.description ?? "None")")
|
||||
}
|
||||
#elseif os(iOS)
|
||||
if UIDevice.current.hasCellularCapabilites {
|
||||
Section(header: Text("Battery")) {
|
||||
Picker("Wi-Fi", selection: $batteryNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Cellular", selection: $batteryCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
Section(header: Text("Charging")) {
|
||||
Picker("Wi-Fi", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Cellular", selection: $chargingCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
} else {
|
||||
nonCellularBatteryDevicesProfilesPickers
|
||||
}
|
||||
#else
|
||||
if Power.hasInternalBattery {
|
||||
nonCellularBatteryDevicesProfilesPickers
|
||||
} else {
|
||||
Picker("Default", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.disabled(qualityProfiles.isEmpty)
|
||||
Section(header: SettingsHeader(text: "Profiles"), footer: profilesFooter) {
|
||||
profilesList
|
||||
|
||||
Button {
|
||||
editedProfile = nil
|
||||
presentingProfileForm = true
|
||||
} label: {
|
||||
Label("Add profile...", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var nonCellularBatteryDevicesProfilesPickers: some View {
|
||||
Picker("Battery", selection: $batteryNonCellularProfile) { profilePickerOptions }
|
||||
Picker("Charging", selection: $chargingNonCellularProfile) { profilePickerOptions }
|
||||
}
|
||||
|
||||
@ViewBuilder func profileControl(_ qualityProfile: QualityProfile) -> some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
QualityProfilesModel.shared.applyToAll(qualityProfile)
|
||||
} label: {
|
||||
Text(qualityProfile.description)
|
||||
}
|
||||
#else
|
||||
Text(qualityProfile.description)
|
||||
#endif
|
||||
}
|
||||
|
||||
var profilePickerOptions: some View {
|
||||
ForEach(qualityProfiles) { qualityProfile in
|
||||
Text(qualityProfile.description).tag(qualityProfile.id)
|
||||
}
|
||||
}
|
||||
|
||||
var profilesFooter: some View {
|
||||
#if os(tvOS)
|
||||
Text("You can switch between profiles in playback settings controls.")
|
||||
#else
|
||||
Text("You can use automatic profile selection based on current device status or switch it in video playback settings controls.")
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var profilesList: some View {
|
||||
let list = ForEach(qualityProfiles) { qualityProfile in
|
||||
profileControl(qualityProfile)
|
||||
.contextMenu {
|
||||
Button {
|
||||
QualityProfilesModel.shared.applyToAll(qualityProfile)
|
||||
} label: {
|
||||
#if os(tvOS)
|
||||
Text("Make default")
|
||||
#elseif os(iOS)
|
||||
Label("Apply to all", systemImage: "wand.and.stars")
|
||||
#else
|
||||
if Power.hasInternalBattery {
|
||||
Text("Apply to all")
|
||||
} else {
|
||||
Text("Make default")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Button {
|
||||
editedProfile = qualityProfile
|
||||
presentingProfileForm = true
|
||||
} label: {
|
||||
Label("Edit...", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
QualityProfilesModel.shared.remove(qualityProfile)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 12.0, *) {
|
||||
#if os(macOS)
|
||||
List {
|
||||
list
|
||||
}
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
#else
|
||||
list
|
||||
#endif
|
||||
} else {
|
||||
#if os(macOS)
|
||||
List {
|
||||
list
|
||||
}
|
||||
#else
|
||||
list
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QualitySettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
#if os(macOS)
|
||||
QualitySettings()
|
||||
#else
|
||||
NavigationView {
|
||||
EmptyView()
|
||||
QualitySettings()
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
|
||||
static let discordURL = URL(string: "https://yattee.stream/discord")!
|
||||
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case locations, browsing, player, history, sponsorBlock, advanced, help
|
||||
case locations, browsing, player, quality, history, sponsorBlock, advanced, help
|
||||
}
|
||||
|
||||
@State private var selection = Tabs.locations
|
||||
@@ -59,6 +58,14 @@ struct SettingsView: View {
|
||||
}
|
||||
.tag(Tabs.player)
|
||||
|
||||
Form {
|
||||
QualitySettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Quality", systemImage: "4k.tv")
|
||||
}
|
||||
.tag(Tabs.quality)
|
||||
|
||||
Form {
|
||||
HistorySettings()
|
||||
}
|
||||
@@ -92,18 +99,14 @@ struct SettingsView: View {
|
||||
.tag(Tabs.help)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 480, height: windowHeight)
|
||||
.frame(width: 520, height: windowHeight)
|
||||
#else
|
||||
Group {
|
||||
NavigationView {
|
||||
settingsList
|
||||
#if os(tvOS)
|
||||
settingsList
|
||||
#else
|
||||
NavigationView {
|
||||
settingsList
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -142,6 +145,12 @@ struct SettingsView: View {
|
||||
Label("Player", systemImage: "play.rectangle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
QualitySettings()
|
||||
} label: {
|
||||
Label("Quality", systemImage: "4k.tv")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
HistorySettings()
|
||||
} label: {
|
||||
@@ -219,6 +228,8 @@ struct SettingsView: View {
|
||||
return 390
|
||||
case .player:
|
||||
return 420
|
||||
case .quality:
|
||||
return 400
|
||||
case .history:
|
||||
return 480
|
||||
case .sponsorBlock:
|
||||
|
Reference in New Issue
Block a user