mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 21:43:41 +00:00
Merge pull request #650 from stonerl/rework-qualitiy-settings
Rework qualitiy settings
This commit is contained in:
commit
201de81351
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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,46 @@ extension PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||||
|
// filter out non HLS streams
|
||||||
|
let nonHLSStreams = streams.filter { $0.kind != .hls }
|
||||||
|
|
||||||
|
// find max resolution from non HLS streams
|
||||||
|
let bestResolution = nonHLSStreams
|
||||||
|
.filter { $0.resolution <= maxResolution.value }
|
||||||
|
.max { $0.resolution < $1.resolution }?.resolution
|
||||||
|
|
||||||
|
return streams.map { stream in
|
||||||
|
if stream.kind == .hls {
|
||||||
|
stream.resolution = bestResolution ?? 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")
|
||||||
|
|
||||||
|
@ -676,7 +676,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()
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,31 @@ 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))" : "")"
|
|
||||||
|
if kind == .hls {
|
||||||
|
return "adaptive (HLS)"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "adaptive (HLS)"
|
||||||
}
|
}
|
||||||
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)" : "adaptive (HLS)\(instanceString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolutionAndFormat: String {
|
var resolutionAndFormat: String {
|
||||||
|
@ -167,11 +167,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 ? [
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -204,6 +204,7 @@ struct YatteeApp: App {
|
|||||||
URLBookmarkModel.shared.refreshAll()
|
URLBookmarkModel.shared.refreshAll()
|
||||||
|
|
||||||
migrateHomeHistoryItems()
|
migrateHomeHistoryItems()
|
||||||
|
migrateQualityProfiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateHomeHistoryItems() {
|
func migrateHomeHistoryItems() {
|
||||||
@ -221,6 +222,16 @@ struct YatteeApp: App {
|
|||||||
Defaults[.homeHistoryItems] = -1
|
Defaults[.homeHistoryItems] = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Default(.qualityProfiles) private var qualityProfilesData
|
||||||
|
|
||||||
|
func migrateQualityProfiles() {
|
||||||
|
for profile in qualityProfilesData where profile.order.isEmpty {
|
||||||
|
var updatedProfile = profile
|
||||||
|
updatedProfile.order = Array(QualityProfile.Format.allCases.indices)
|
||||||
|
QualityProfilesModel.shared.update(profile, updatedProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var navigationStyle: NavigationStyle {
|
var navigationStyle: NavigationStyle {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return horizontalSizeClass == .compact ? .tab : .sidebar
|
return horizontalSizeClass == .compact ? .tab : .sidebar
|
||||||
|
Loading…
Reference in New Issue
Block a user