rework quality settings

- The order of the formats can now be changed in the Quality Settings.
- sortingOrder is now part of QualitiyProfile.
- bestPlayable is now part of PlayerBackend.
- hls and stream aren't treated as unknown anymore.
This commit is contained in:
Toni Förster 2024-04-26 12:27:25 +02:00
parent d1cf45c6a1
commit 54915dcea1
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
10 changed files with 191 additions and 151 deletions

View File

@ -116,16 +116,6 @@ final class AVPlayerBackend: PlayerBackend {
#endif #endif
} }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
let sortedByResolution = streams
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
.sorted { $0.resolution > $1.resolution }
return streams.first { $0.kind == .hls } ??
sortedByResolution.first { $0.kind == .stream } ??
sortedByResolution.first
}
func canPlay(_ stream: Stream) -> Bool { func canPlay(_ stream: Stream) -> Bool {
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4) stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
} }

View File

@ -201,29 +201,6 @@ final class MPVBackend: PlayerBackend {
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
streams
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
.max { lhs, rhs in
let predicates: [AreInIncreasingOrder] = [
{ $0.resolution < $1.resolution },
{ $0.format > $1.format }
]
for predicate in predicates {
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
} ??
streams.first { $0.kind == .hls } ??
streams.first
}
func canPlay(_ stream: Stream) -> Bool { func canPlay(_ stream: Stream) -> Bool {
stream.resolution != .unknown && stream.format != .av1 stream.resolution != .unknown && stream.format != .av1
} }

View File

@ -29,7 +29,6 @@ protocol PlayerBackend {
var videoWidth: Double? { get } var videoWidth: Double? { get }
var videoHeight: Double? { get } var videoHeight: Double? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
func canPlay(_ stream: Stream) -> Bool func canPlay(_ stream: Stream) -> Bool
func canPlayAtRate(_ rate: Double) -> Bool func canPlayAtRate(_ rate: Double) -> Bool
@ -131,6 +130,38 @@ extension PlayerBackend {
} }
} }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
return streams.map { stream in
if stream.kind == .hls {
stream.resolution = maxResolution.value
stream.format = .hls
} else if stream.kind == .stream {
stream.format = .stream
}
return stream
}
.filter { stream in
stream.resolution <= maxResolution.value
}
.max { lhs, rhs in
if lhs.resolution == rhs.resolution {
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
else {
print("Failed to extract lhsFormat or rhsFormat")
return false
}
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
return lhsFormatIndex > rhsFormatIndex
}
return lhs.resolution < rhs.resolution
}
}
func updateControls(completionHandler: (() -> Void)? = nil) { func updateControls(completionHandler: (() -> Void)? = nil) {
print("updating controls") print("updating controls")

View File

@ -673,7 +673,7 @@ final class PlayerModel: ObservableObject {
} }
guard let video = currentVideo else { return } guard let video = currentVideo else { return }
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return } guard let stream = backend.bestPlayable(availableStreams, maxResolution: .hd720p30, formatOrder: qualityProfile!.formats) else { return }
exitFullScreen() exitFullScreen()

View File

@ -127,12 +127,12 @@ extension PlayerModel {
if let streamPreferredForProfile = backend.bestPlayable( if let streamPreferredForProfile = backend.bestPlayable(
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) }, availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
maxResolution: profile.resolution maxResolution: profile.resolution, formatOrder: profile.formats
) { ) {
return streamPreferredForProfile return streamPreferredForProfile
} }
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution) return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution, formatOrder: profile.formats)
} }
func advanceToNextItem() { func advanceToNextItem() {

View File

@ -3,13 +3,13 @@ import Foundation
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
static var bridge = QualityProfileBridge() static var bridge = QualityProfileBridge()
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream]) static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable { enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
case hls case hls
case stream case stream
case mp4
case avc1 case avc1
case mp4
case av1 case av1
case webm case webm
@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return "Stream" return "Stream"
case .webm: case .webm:
return "WebM" return "WebM"
default: default:
return rawValue.uppercased() return rawValue.uppercased()
} }
@ -35,14 +34,14 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
return nil return nil
case .stream: case .stream:
return nil return nil
case .mp4:
return .mp4
case .webm:
return .webm
case .avc1: case .avc1:
return .avc1 return .avc1
case .mp4:
return .mp4
case .av1: case .av1:
return .av1 return .av1
case .webm:
return .webm
} }
} }
} }
@ -53,7 +52,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
var backend: PlayerBackendType var backend: PlayerBackendType
var resolution: ResolutionSetting var resolution: ResolutionSetting
var formats: [Format] var formats: [Format]
var order: [Int]
var description: String { var description: String {
if let name, !name.isEmpty { return name } if let name, !name.isEmpty { return name }
return "\(backend.label) - \(resolution.description) - \(formatsDescription)" return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
@ -101,7 +100,8 @@ struct QualityProfileBridge: Defaults.Bridge {
"name": value.name ?? "", "name": value.name ?? "",
"backend": value.backend.rawValue, "backend": value.backend.rawValue,
"resolution": value.resolution.rawValue, "resolution": value.resolution.rawValue,
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator) "formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator),
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
] ]
} }
@ -116,7 +116,8 @@ struct QualityProfileBridge: Defaults.Bridge {
let name = object["name"] let name = object["name"]
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) } let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats) return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
} }
} }

View File

@ -64,7 +64,7 @@ class Stream: Equatable, Hashable, Identifiable {
} }
enum Kind: String, Comparable { enum Kind: String, Comparable {
case stream, adaptive, hls case hls, adaptive, stream
private var sortOrder: Int { private var sortOrder: Int {
switch self { switch self {
@ -82,37 +82,23 @@ class Stream: Equatable, Hashable, Identifiable {
} }
} }
enum Format: String, Comparable { enum Format: String {
case webm
case avc1 case avc1
case av1
case mp4 case mp4
case av1
case webm
case hls
case stream
case unknown case unknown
private var sortOrder: Int {
switch self {
case .mp4:
return 0
case .avc1:
return 1
case .av1:
return 2
case .webm:
return 3
case .unknown:
return 4
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
var description: String { var description: String {
switch self { switch self {
case .webm: case .webm:
return "WebM" return "WebM"
case .hls:
return "adaptive (HLS)"
case .stream:
return "Stream"
default: default:
return rawValue.uppercased() return rawValue.uppercased()
} }
@ -121,17 +107,23 @@ class Stream: Equatable, Hashable, Identifiable {
static func from(_ string: String) -> Self { static func from(_ string: String) -> Self {
let lowercased = string.lowercased() let lowercased = string.lowercased()
if lowercased.contains("webm") {
return .webm
}
if lowercased.contains("avc1") { if lowercased.contains("avc1") {
return .avc1 return .avc1
} }
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
return .mp4
}
if lowercased.contains("av01") { if lowercased.contains("av01") {
return .av1 return .av1
} }
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") { if lowercased.contains("webm") {
return .mp4 return .webm
}
if lowercased.contains("stream") {
return .stream
}
if lowercased.contains("hls") {
return .hls
} }
return .unknown return .unknown
} }
@ -184,22 +176,26 @@ class Stream: Equatable, Hashable, Identifiable {
var quality: String { var quality: String {
guard localURL.isNil else { return "Opened File" } guard localURL.isNil else { return "Opened File" }
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")" return resolution.name
} }
var shortQuality: String { var shortQuality: String {
guard localURL.isNil else { return "File" } guard localURL.isNil else { return "File" }
if kind == .hls { if kind == .hls {
return "HLS" return format.description
} }
return resolution?.name ?? "?"
if kind == .stream {
return resolution.name
}
return resolutionAndFormat
} }
var description: String { var description: String {
guard localURL.isNil else { return resolutionAndFormat } guard localURL.isNil else { return resolutionAndFormat }
let instanceString = instance.isNil ? "" : " - (\(instance!.description))" let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
return "\(resolutionAndFormat)\(instanceString)" return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "\(format.description)\(instanceString)"
} }
var resolutionAndFormat: String { var resolutionAndFormat: String {

View File

@ -165,11 +165,11 @@ extension Defaults.Keys {
// MARK: GROUP - Quality // MARK: GROUP - Quality
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases) static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases) static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases) static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream]) static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream]) static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices))
#if os(iOS) #if os(iOS)
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [ static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [

View File

@ -9,9 +9,23 @@ struct MultiselectRow: View {
@State private var toggleChecked = false @State private var toggleChecked = false
var body: some View { var body: some View {
#if os(macOS) #if os(tvOS)
Button(action: { action(!selected) }) {
HStack {
Text(self.title)
Spacer()
if selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}
.disabled(disabled)
#else
Toggle(title, isOn: $toggleChecked) Toggle(title, isOn: $toggleChecked)
#if os(macOS)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
#endif
.onAppear { .onAppear {
guard !disabled else { return } guard !disabled else { return }
toggleChecked = selected toggleChecked = selected
@ -19,24 +33,6 @@ struct MultiselectRow: View {
.onChange(of: toggleChecked) { new in .onChange(of: toggleChecked) { new in
action(new) action(new)
} }
#else
Button(action: { action(!selected) }) {
HStack {
Text(self.title)
Spacer()
if selected {
Image(systemName: "checkmark")
#if os(iOS)
.foregroundColor(.accentColor)
#endif
}
}
.contentShape(Rectangle())
}
.disabled(disabled)
#if !os(tvOS)
.buttonStyle(.plain)
#endif
#endif #endif
} }
} }

View File

@ -1,6 +1,11 @@
import Defaults import Defaults
import SwiftUI import SwiftUI
struct FormatState: Equatable {
let format: QualityProfile.Format
var isActive: Bool
}
struct QualityProfileForm: View { struct QualityProfileForm: View {
@Binding var qualityProfileID: QualityProfile.ID? @Binding var qualityProfileID: QualityProfile.ID?
@ -15,6 +20,7 @@ struct QualityProfileForm: View {
@State private var backend = PlayerBackendType.mpv @State private var backend = PlayerBackendType.mpv
@State private var resolution = ResolutionSetting.hd1080p60 @State private var resolution = ResolutionSetting.hd1080p60
@State private var formats = [QualityProfile.Format]() @State private var formats = [QualityProfile.Format]()
@State private var orderedFormats: [FormatState] = []
@Default(.qualityProfiles) private var qualityProfiles @Default(.qualityProfiles) private var qualityProfiles
@ -26,6 +32,7 @@ struct QualityProfileForm: View {
return nil return nil
} }
// swiftlint:disable trailing_closure
var body: some View { var body: some View {
VStack { VStack {
Group { Group {
@ -40,8 +47,11 @@ struct QualityProfileForm: View {
#endif #endif
.onAppear(perform: initializeForm) .onAppear(perform: initializeForm)
.onChange(of: backend, perform: backendChanged) .onChange(of: backend, perform: { _ in backendChanged(self.backend); updateActiveFormats(); validate() })
.onChange(of: formats) { _ in validate() }
.onChange(of: name, perform: { _ in validate() })
.onChange(of: resolution, perform: { _ in validate() })
.onChange(of: orderedFormats, perform: { _ in validate() })
#if os(iOS) #if os(iOS)
.padding(.vertical) .padding(.vertical)
#elseif os(tvOS) #elseif os(tvOS)
@ -53,6 +63,8 @@ struct QualityProfileForm: View {
#endif #endif
} }
// swiftlint:enable trailing_closure
var header: some View { var header: some View {
HStack { HStack {
Text(editing ? "Edit Quality Profile" : "Add Quality Profile") Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
@ -124,9 +136,16 @@ struct QualityProfileForm: View {
} }
var formatsFooter: some View { var formatsFooter: some View {
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).") VStack(alignment: .leading) {
.foregroundColor(.secondary) Text("Formats can be reordered and will be selected in this order.")
.fixedSize(horizontal: false, vertical: true) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("**Note:** HLS is an adaptive format, resolution setting doesn't apply.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 0.1)
}
.padding(.top, 2)
} }
@ViewBuilder var qualityPicker: some View { @ViewBuilder var qualityPicker: some View {
@ -199,17 +218,25 @@ struct QualityProfileForm: View {
#endif #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 { @ViewBuilder var formatsPicker: some View {
#if os(macOS) #if os(macOS)
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in let list = filteredFormatList
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
Group { Group {
if #available(macOS 12.0, *) { if #available(macOS 12.0, *) {
@ -222,28 +249,19 @@ struct QualityProfileForm: View {
} }
Spacer() Spacer()
#else #else
ForEach(QualityProfile.Format.allCases, id: \.self) { format in filteredFormatList
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
#endif #endif
} }
func isFormatSelected(_ format: QualityProfile.Format) -> Bool { func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
(initialized || qualityProfile.isNil ? formats : qualityProfile.formats).contains(format) return orderedFormats.first { $0.format == format }?.isActive ?? false
} }
func toggleFormat(_ format: QualityProfile.Format, value: Bool) { func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
if let index = formats.firstIndex(where: { $0 == format }), !value { if let index = orderedFormats.firstIndex(where: { $0.format == format }) {
formats.remove(at: index) orderedFormats[index].isActive = value
} else if value {
formats.append(format)
} }
validate() // Check validity after a toggle operation
} }
var footer: some View { var footer: some View {
@ -274,6 +292,12 @@ struct QualityProfileForm: View {
return !avPlayerFormats.contains(format) 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 { func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
guard backend == .appleAVPlayer else { return false } guard backend == .appleAVPlayer else { return false }
@ -281,27 +305,39 @@ struct QualityProfileForm: View {
} }
func initializeForm() { func initializeForm() {
guard editing else { if editing {
validate() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
return 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
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.name = qualityProfile.name ?? ""
self.backend = qualityProfile.backend
self.resolution = qualityProfile.resolution
self.formats = .init(qualityProfile.formats)
self.initialized = true
}
validate() validate()
} }
func backendChanged(_: PlayerBackendType) { func backendChanged(_: PlayerBackendType) {
formats.filter { isFormatDisabled($0) }.forEach { format in let defaultFormats = QualityProfile.Format.allCases.map {
if let index = formats.firstIndex(where: { $0 == format }) { FormatState(format: $0, isActive: true)
formats.remove(at: index) }
}
if backend == .appleAVPlayer {
orderedFormats = orderedFormats.filter { !isFormatDisabled($0.format) }
} else {
orderedFormats = defaultFormats
} }
if isResolutionDisabled(resolution), if isResolutionDisabled(resolution),
@ -312,20 +348,33 @@ struct QualityProfileForm: View {
} }
func validate() { func validate() {
valid = !formats.isEmpty 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() { func submitForm() {
guard valid else { return } guard valid else { return }
formats = formats.unique() let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format }
let formProfile = QualityProfile( let formProfile = QualityProfile(
id: qualityProfile?.id ?? UUID().uuidString, id: qualityProfile?.id ?? UUID().uuidString,
name: name, name: name,
backend: backend, backend: backend,
resolution: resolution, resolution: resolution,
formats: Array(formats) formats: activeFormats,
order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! }
) )
if editing { if editing {