diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index 74b18cc3..58838f91 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -116,16 +116,6 @@ final class AVPlayerBackend: PlayerBackend { #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 { stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4) } diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 836173d0..2aaae71d 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -201,29 +201,6 @@ final class MPVBackend: PlayerBackend { 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 { stream.resolution != .unknown && stream.format != .av1 } diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index 3b09e957..fce56900 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -29,7 +29,6 @@ protocol PlayerBackend { var videoWidth: Double? { get } var videoHeight: Double? { get } - func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? func canPlay(_ stream: Stream) -> 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) { print("updating controls") diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index a5a84e74..047deca0 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -676,7 +676,7 @@ final class PlayerModel: ObservableObject { } 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() diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index c2f8256e..cbec911d 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -127,12 +127,12 @@ extension PlayerModel { if let streamPreferredForProfile = backend.bestPlayable( availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) }, - maxResolution: profile.resolution + maxResolution: profile.resolution, formatOrder: profile.formats ) { 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() { diff --git a/Model/QualityProfile.swift b/Model/QualityProfile.swift index 28775eb0..6906d581 100644 --- a/Model/QualityProfile.swift +++ b/Model/QualityProfile.swift @@ -3,13 +3,13 @@ import Foundation struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { 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 { case hls case stream - case mp4 case avc1 + case mp4 case av1 case webm @@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { return "Stream" case .webm: return "WebM" - default: return rawValue.uppercased() } @@ -35,14 +34,14 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { return nil case .stream: return nil - case .mp4: - return .mp4 - case .webm: - return .webm case .avc1: return .avc1 + case .mp4: + return .mp4 case .av1: return .av1 + case .webm: + return .webm } } } @@ -53,7 +52,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { var backend: PlayerBackendType var resolution: ResolutionSetting var formats: [Format] - + var order: [Int] var description: String { if let name, !name.isEmpty { return name } return "\(backend.label) - \(resolution.description) - \(formatsDescription)" @@ -101,7 +100,8 @@ struct QualityProfileBridge: Defaults.Bridge { "name": value.name ?? "", "backend": value.backend.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 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) } } diff --git a/Model/Stream.swift b/Model/Stream.swift index 4f58e637..65e58993 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -64,7 +64,7 @@ class Stream: Equatable, Hashable, Identifiable { } enum Kind: String, Comparable { - case stream, adaptive, hls + case hls, adaptive, stream private var sortOrder: Int { switch self { @@ -82,37 +82,23 @@ class Stream: Equatable, Hashable, Identifiable { } } - enum Format: String, Comparable { - case webm + enum Format: String { case avc1 - case av1 case mp4 + case av1 + case webm + case hls + case stream 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 { switch self { case .webm: return "WebM" - + case .hls: + return "adaptive (HLS)" + case .stream: + return "Stream" default: return rawValue.uppercased() } @@ -121,17 +107,23 @@ class Stream: Equatable, Hashable, Identifiable { static func from(_ string: String) -> Self { let lowercased = string.lowercased() - if lowercased.contains("webm") { - return .webm - } if lowercased.contains("avc1") { return .avc1 } + if lowercased.contains("mpeg_4") || lowercased.contains("mp4") { + return .mp4 + } if lowercased.contains("av01") { return .av1 } - if lowercased.contains("mpeg_4") || lowercased.contains("mp4") { - return .mp4 + if lowercased.contains("webm") { + return .webm + } + if lowercased.contains("stream") { + return .stream + } + if lowercased.contains("hls") { + return .hls } return .unknown } @@ -184,22 +176,31 @@ class Stream: Equatable, Hashable, Identifiable { var quality: String { 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 { guard localURL.isNil else { return "File" } if kind == .hls { - return "HLS" + return "adaptive (HLS)" } - return resolution?.name ?? "?" + + if kind == .stream { + return resolution.name + } + return resolutionAndFormat } var description: String { guard localURL.isNil else { return resolutionAndFormat } let instanceString = instance.isNil ? "" : " - (\(instance!.description))" - return "\(resolutionAndFormat)\(instanceString)" + return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)" } var resolutionAndFormat: String { diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 114504ff..51dd3dcb 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -167,11 +167,11 @@ extension Defaults.Keys { // MARK: GROUP - Quality - static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases) - static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases) - static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases) - static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream]) - static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream]) + 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, order: Array(QualityProfile.Format.allCases.indices)) + 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], order: Array(QualityProfile.Format.allCases.indices)) + static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream], order: Array(QualityProfile.Format.allCases.indices)) #if os(iOS) static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [ diff --git a/Shared/Settings/MultiselectRow.swift b/Shared/Settings/MultiselectRow.swift index 826868e0..24c1a05d 100644 --- a/Shared/Settings/MultiselectRow.swift +++ b/Shared/Settings/MultiselectRow.swift @@ -9,9 +9,23 @@ struct MultiselectRow: View { @State private var toggleChecked = false 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) + #if os(macOS) .toggleStyle(.checkbox) + #endif .onAppear { guard !disabled else { return } toggleChecked = selected @@ -19,24 +33,6 @@ struct MultiselectRow: View { .onChange(of: toggleChecked) { new in 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 } } diff --git a/Shared/Settings/QualityProfileForm.swift b/Shared/Settings/QualityProfileForm.swift index daade631..18a3fe48 100644 --- a/Shared/Settings/QualityProfileForm.swift +++ b/Shared/Settings/QualityProfileForm.swift @@ -1,6 +1,11 @@ import Defaults import SwiftUI +struct FormatState: Equatable { + let format: QualityProfile.Format + var isActive: Bool +} + struct QualityProfileForm: View { @Binding var qualityProfileID: QualityProfile.ID? @@ -15,6 +20,7 @@ struct QualityProfileForm: View { @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 @@ -26,6 +32,7 @@ struct QualityProfileForm: View { return nil } + // swiftlint:disable trailing_closure var body: some View { VStack { Group { @@ -40,8 +47,11 @@ struct QualityProfileForm: View { #endif .onAppear(perform: initializeForm) - .onChange(of: backend, perform: backendChanged) - .onChange(of: formats) { _ in validate() } + .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) @@ -53,6 +63,8 @@ struct QualityProfileForm: View { #endif } + // swiftlint:enable trailing_closure + var header: some View { HStack { Text(editing ? "Edit Quality Profile" : "Add Quality Profile") @@ -124,9 +136,16 @@ struct QualityProfileForm: View { } 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) + 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, resolution setting doesn't apply.") + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 0.1) + } + .padding(.top, 2) } @ViewBuilder var qualityPicker: some View { @@ -199,17 +218,25 @@ struct QualityProfileForm: View { #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 = ForEach(QualityProfile.Format.allCases, id: \.self) { format in - MultiselectRow( - title: format.description, - selected: isFormatSelected(format), - disabled: isFormatDisabled(format) - ) { value in - toggleFormat(format, value: value) - } - } + let list = filteredFormatList Group { if #available(macOS 12.0, *) { @@ -222,28 +249,19 @@ struct QualityProfileForm: View { } 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) - } - } + filteredFormatList #endif } 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) { - if let index = formats.firstIndex(where: { $0 == format }), !value { - formats.remove(at: index) - } else if value { - formats.append(format) + if let index = orderedFormats.firstIndex(where: { $0.format == format }) { + orderedFormats[index].isActive = value } + validate() // Check validity after a toggle operation } var footer: some View { @@ -274,6 +292,12 @@ struct QualityProfileForm: View { 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 } @@ -281,27 +305,39 @@ struct QualityProfileForm: View { } func initializeForm() { - guard editing else { - validate() - return + 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 } - - 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() } func backendChanged(_: PlayerBackendType) { - formats.filter { isFormatDisabled($0) }.forEach { format in - if let index = formats.firstIndex(where: { $0 == format }) { - formats.remove(at: index) - } + 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), @@ -312,20 +348,33 @@ struct QualityProfileForm: View { } 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() { guard valid else { return } - formats = formats.unique() + let activeFormats = orderedFormats.filter { $0.isActive }.map { $0.format } let formProfile = QualityProfile( id: qualityProfile?.id ?? UUID().uuidString, name: name, backend: backend, resolution: resolution, - formats: Array(formats) + formats: activeFormats, + order: orderedFormats.map { QualityProfile.Format.allCases.firstIndex(of: $0.format)! } ) if editing { diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index b9422cc1..1975706b 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -204,6 +204,7 @@ struct YatteeApp: App { URLBookmarkModel.shared.refreshAll() migrateHomeHistoryItems() + migrateQualityProfiles() } func migrateHomeHistoryItems() { @@ -221,6 +222,16 @@ struct YatteeApp: App { 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 { #if os(iOS) return horizontalSizeClass == .compact ? .tab : .sidebar