diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift index 120602c5..e45085f7 100644 --- a/Model/Applications/PeerTubeAPI.swift +++ b/Model/Applications/PeerTubeAPI.swift @@ -515,7 +515,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { .dictionaryValue["files"]?.arrayValue.first? .dictionaryValue["fileUrl"]?.url { - streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream)) + let resolution = Stream.Resolution.predefined(.hd720p30) + streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream)) } return streams diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 942589ba..936c4d76 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -204,7 +204,7 @@ final class MPVBackend: PlayerBackend { typealias AreInIncreasingOrder = (Stream, Stream) -> Bool func canPlay(_ stream: Stream) -> Bool { - stream.resolution != .unknown && stream.format != .av1 + stream.format != .av1 } func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) { diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index ecf3bb58..9280233a 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -153,8 +153,9 @@ extension PlayerBackend { // Filter out non-HLS streams and streams with resolution more than maxResolution let nonHLSStreams = streams.filter { let isHLS = $0.kind == .hls - // Safely unwrap resolution and maxResolution.value to avoid crashes - let isWithinResolution = ($0.resolution != nil && maxResolution.value != nil) ? $0.resolution! <= maxResolution.value! : false + // Check if the stream's resolution is within the maximum allowed resolution + let isWithinResolution = $0.resolution.map { $0 <= maxResolution.value } ?? false + logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)") logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)") return !isHLS && isWithinResolution @@ -188,8 +189,8 @@ extension PlayerBackend { } let filteredStreams = adjustedStreams.filter { stream in - // Safely unwrap resolution and maxResolution.value to avoid crashes - let isWithinResolution = (stream.resolution != nil && maxResolution.value != nil) ? stream.resolution! <= maxResolution.value! : false + // Check if the stream's resolution is within the maximum allowed resolution + let isWithinResolution = stream.resolution <= maxResolution.value logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)") return isWithinResolution } diff --git a/Model/QualityProfile.swift b/Model/QualityProfile.swift index ea7e3fa3..c731b2da 100644 --- a/Model/QualityProfile.swift +++ b/Model/QualityProfile.swift @@ -76,7 +76,8 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable { return true } - let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution + let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30) + let resolutionMatch = resolution.value ?? defaultResolution >= stream.resolution if resolutionMatch, formats.contains(.stream), stream.kind == .stream { return true diff --git a/Model/Stream.swift b/Model/Stream.swift index efab666a..fca53df2 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -4,292 +4,126 @@ import Foundation // swiftlint:disable:next final_class class Stream: Equatable, Hashable, Identifiable { - enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable { - // Some 16:19 and 16:10 resolutions are also used in 2:1 videos + enum Resolution: Comparable, Codable, Defaults.Serializable { + case predefined(PredefinedResolution) + case custom(height: Int, refreshRate: Int) - // 8K UHD (16:9) Resolutions - case hd4320p60 - case hd4320p50 - case hd4320p48 - case hd4320p30 - case hd4320p25 - case hd4320p24 + enum PredefinedResolution: String, CaseIterable, Codable { + // 8K UHD (16:9) Resolutions + case hd4320p60, hd4320p30 - // 5K (16:9) Resolutions - case hd2560p60 - case hd2560p50 - case hd2560p48 - case hd2560p30 - case hd2560p25 - case hd2560p24 + // 4K UHD (16:9) Resolutions + case hd2160p60, hd2160p30 - // 2:1 Aspect Ratio (Univisium) Resolutions - case hd2880p60 - case hd2880p50 - case hd2880p48 - case hd2880p30 - case hd2880p25 - case hd2880p24 + // 1440p (16:9) Resolutions + case hd1440p60, hd1440p30 - // 16:10 Resolutions - case hd2400p60 - case hd2400p50 - case hd2400p48 - case hd2400p30 - case hd2400p25 - case hd2400p24 + // 1080p (Full HD, 16:9) Resolutions + case hd1080p60, hd1080p30 - // 16:9 Resolutions - case hd2160p60 - case hd2160p50 - case hd2160p48 - case hd2160p30 - case hd2160p25 - case hd2160p24 + // 720p (HD, 16:9) Resolutions + case hd720p60, hd720p30 - // 16:10 Resolutions - case hd1600p60 - case hd1600p50 - case hd1600p48 - case hd1600p30 - case hd1600p25 - case hd1600p24 - - // 16:9 Resolutions - case hd1440p60 - case hd1440p50 - case hd1440p48 - case hd1440p30 - case hd1440p25 - case hd1440p24 - - // 16:10 Resolutions - case hd1280p60 - case hd1280p50 - case hd1280p48 - case hd1280p30 - case hd1280p25 - case hd1280p24 - - // 16:10 Resolutions - case hd1200p60 - case hd1200p50 - case hd1200p48 - case hd1200p30 - case hd1200p25 - case hd1200p24 - - // 16:9 Resolutions - case hd1080p60 - case hd1080p50 - case hd1080p48 - case hd1080p30 - case hd1080p25 - case hd1080p24 - - // 16:10 Resolutions - case hd1050p60 - case hd1050p50 - case hd1050p48 - case hd1050p30 - case hd1050p25 - case hd1050p24 - - // 16:9 Resolutions - case hd960p60 - case hd960p50 - case hd960p48 - case hd960p30 - case hd960p25 - case hd960p24 - - // 16:10 Resolutions - case hd900p60 - case hd900p50 - case hd900p48 - case hd900p30 - case hd900p25 - case hd900p24 - - // 16:10 Resolutions - case hd800p60 - case hd800p50 - case hd800p48 - case hd800p30 - case hd800p25 - case hd800p24 - - // 16:9 Resolutions - case hd720p60 - case hd720p50 - case hd720p48 - case hd720p30 - case hd720p25 - case hd720p24 - - // Standard Definition (SD) Resolutions - case sd854p30 - case sd854p25 - case sd768p30 - case sd768p25 - case sd640p30 - case sd640p25 - case sd480p30 - case sd480p25 - - case sd428p30 - case sd428p25 - case sd426p30 - case sd426p25 - case sd360p30 - case sd360p25 - case sd320p30 - case sd320p25 - case sd256p30 - case sd256p25 - case sd240p30 - case sd240p25 - case sd214p30 - case sd214p25 - case sd144p30 - case sd144p25 - case sd128p30 - case sd128p25 - - case unknown + // Standard Definition (SD) Resolutions + case sd480p30 + case sd360p30 + case sd240p30 + case sd144p30 + } var name: String { - "\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")" + switch self { + case let .predefined(predefined): + return predefined.rawValue + case let .custom(height, refreshRate): + return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")" + } } var height: Int { - if self == .unknown { - return -1 + switch self { + case let .predefined(predefined): + return predefined.height + case let .custom(height, _): + return height } - - let resolutionPart = rawValue.components(separatedBy: "p").first! - return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())! } var refreshRate: Int { - if self == .unknown { - return -1 + switch self { + case let .predefined(predefined): + return predefined.refreshRate + case let .custom(_, refreshRate): + return refreshRate } - - let refreshRatePart = rawValue.components(separatedBy: "p")[1] - - if refreshRatePart.isEmpty { - return 30 - } - - return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1 } - // These values are an approximation. - // https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate - var bitrate: Int { switch self { - // 8K UHD (16:9) Resolutions - case .hd4320p60, .hd4320p50, .hd4320p48, .hd4320p30, .hd4320p25, .hd4320p24: - return 85_000_000 // 85 Mbit/s - - // 5K (16:9) Resolutions - case .hd2880p60, .hd2880p50, .hd2880p48, .hd2880p30, .hd2880p25, .hd2880p24: - return 45_000_000 // 45 Mbit/s - - // 2:1 Aspect Ratio (Univisium) Resolutions - case .hd2560p60, .hd2560p50, .hd2560p48, .hd2560p30, .hd2560p25, .hd2560p24: - return 30_000_000 // 30 Mbit/s - - // 16:10 Resolutions - case .hd2400p60, .hd2400p50, .hd2400p48, .hd2400p30, .hd2400p25, .hd2400p24: - return 35_000_000 // 35 Mbit/s - - // 4K UHD (16:9) Resolutions - case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30, .hd2160p25, .hd2160p24: - return 56_000_000 // 56 Mbit/s - - // 16:10 Resolutions - case .hd1600p60, .hd1600p50, .hd1600p48, .hd1600p30, .hd1600p25, .hd1600p24: - return 20_000_000 // 20 Mbit/s - - // 1440p (16:9) Resolutions - case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30, .hd1440p25, .hd1440p24: - return 24_000_000 // 24 Mbit/s - - // 1280p (16:10) Resolutions - case .hd1280p60, .hd1280p50, .hd1280p48, .hd1280p30, .hd1280p25, .hd1280p24: - return 15_000_000 // 15 Mbit/s - - // 1200p (16:10) Resolutions - case .hd1200p60, .hd1200p50, .hd1200p48, .hd1200p30, .hd1200p25, .hd1200p24: - return 18_000_000 // 18 Mbit/s - - // 1080p (16:9) Resolutions - case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30, .hd1080p25, .hd1080p24: - return 12_000_000 // 12 Mbit/s - - // 1050p (16:10) Resolutions - case .hd1050p60, .hd1050p50, .hd1050p48, .hd1050p30, .hd1050p25, .hd1050p24: - return 10_000_000 // 10 Mbit/s - - // 960p Resolutions - case .hd960p60, .hd960p50, .hd960p48, .hd960p30, .hd960p25, .hd960p24: - return 8_000_000 // 8 Mbit/s - - // 900p (16:10) Resolutions - case .hd900p60, .hd900p50, .hd900p48, .hd900p30, .hd900p25, .hd900p24: - return 7_000_000 // 7 Mbit/s - - // 800p (16:10) Resolutions - case .hd800p60, .hd800p50, .hd800p48, .hd800p30, .hd800p25, .hd800p24: - return 6_000_000 // 6 Mbit/s - - // 720p (16:9) Resolutions - case .hd720p60, .hd720p50, .hd720p48, .hd720p30, .hd720p25, .hd720p24: - return 9_500_000 // 9.5 Mbit/s - - // Standard Definition (SD) Resolutions - case .sd854p30, .sd854p25, .sd768p30, .sd768p25, .sd640p30, .sd640p25: - return 4_000_000 // 4 Mbit/s - - case .sd480p30, .sd480p25: - return 2_500_000 // 2.5 Mbit/s - - case .sd428p30, .sd428p25, .sd426p30, .sd426p25: - return 2_000_000 // 2 Mbit/s - - case .sd360p30, .sd360p25: - return 1_500_000 // 1.5 Mbit/s - - case .sd320p30, .sd320p25: - return 1_200_000 // 1.2 Mbit/s - - case .sd256p30, .sd256p25, .sd240p30, .sd240p25: - return 1_000_000 // 1 Mbit/s - - case .sd214p30, .sd214p25: - return 800_000 // 0.8 Mbit/s - - case .sd144p30, .sd144p25: - return 600_000 // 0.6 Mbit/s - - case .sd128p30, .sd128p25: - return 400_000 // 0.4 Mbit/s - - case .unknown: - return 0 + case let .predefined(predefined): + return predefined.bitrate + case let .custom(height, refreshRate): + // Find the closest predefined resolution based on height and refresh rate + let closestPredefined = Stream.Resolution.PredefinedResolution.allCases.min { + abs($0.height - height) + abs($0.refreshRate - refreshRate) < + abs($1.height - height) + abs($1.refreshRate - refreshRate) + } + // Return the bitrate of the closest predefined resolution or a default bitrate if no close match is found + return closestPredefined?.bitrate ?? 5_000_000 } } static func from(resolution: String, fps: Int? = nil) -> Self { - allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown + if let predefined = PredefinedResolution(rawValue: resolution) { + return .predefined(predefined) + } + + // Attempt to parse height and refresh rate + if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 { + let refreshRate = fps ?? 30 + return .custom(height: height, refreshRate: refreshRate) + } + + // Default behavior if parsing fails + return .custom(height: 720, refreshRate: 30) } static func < (lhs: Self, rhs: Self) -> Bool { lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height) } + + enum CodingKeys: String, CodingKey { + case predefined + case custom + case height + case refreshRate + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let predefinedValue = try? container.decode(PredefinedResolution.self, forKey: .predefined) { + self = .predefined(predefinedValue) + } else if let height = try? container.decode(Int.self, forKey: .height), + let refreshRate = try? container.decode(Int.self, forKey: .refreshRate) + { + self = .custom(height: height, refreshRate: refreshRate) + } else { + // Set default resolution to 720p 30 if decoding fails + self = .custom(height: 720, refreshRate: 30) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .predefined(predefinedValue): + try container.encode(predefinedValue, forKey: .predefined) + case let .custom(height, refreshRate): + try container.encode(height, forKey: .height) + try container.encode(refreshRate, forKey: .refreshRate) + } + } } enum Kind: String, Comparable { @@ -482,3 +316,97 @@ class Stream: Equatable, Hashable, Identifiable { } } } + +extension Stream.Resolution.PredefinedResolution { + var height: Int { + switch self { + // 8K UHD (16:9) Resolutions + case .hd4320p60, .hd4320p30: + return 4320 + + // 4K UHD (16:9) Resolutions + case .hd2160p60, .hd2160p30: + return 2160 + + // 1440p (16:9) Resolutions + case .hd1440p60, .hd1440p30: + return 1440 + + // 1080p (Full HD, 16:9) Resolutions + case .hd1080p60, .hd1080p30: + return 1080 + + // 720p (HD, 16:9) Resolutions + case .hd720p60, .hd720p30: + return 720 + + // Standard Definition (SD) Resolutions + case .sd480p30: + return 480 + + case .sd360p30: + return 360 + + case .sd240p30: + return 240 + + case .sd144p30: + return 144 + } + } + + var refreshRate: Int { + switch self { + // 60 fps Resolutions + case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60: + return 60 + + // 30 fps Resolutions + case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30, + .sd480p30, .sd360p30, .sd240p30, .sd144p30: + return 30 + } + } + + // These values are an approximation. + // https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate + + var bitrate: Int { + switch self { + // 8K UHD (16:9) Resolutions + case .hd4320p60: + return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps + case .hd4320p30: + return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps + // 4K UHD (16:9) Resolutions + case .hd2160p60: + return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps + case .hd2160p30: + return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps + // 1440p (2K) Resolutions + case .hd1440p60: + return 24_000_000 // 24 Mbps + case .hd1440p30: + return 16_000_000 // 16 Mbps + // 1080p (Full HD, 16:9) Resolutions + case .hd1080p60: + return 12_000_000 // 12 Mbps + case .hd1080p30: + return 8_000_000 // 8 Mbps + // 720p (HD, 16:9) Resolutions + case .hd720p60: + return 7_500_000 // 7.5 Mbps + case .hd720p30: + return 5_000_000 // 5 Mbps + // Standard Definition (SD) Resolutions + case .sd480p30: + return 2_500_000 // 2.5 Mbps + case .sd360p30: + return 1_000_000 // 1 Mbps + case .sd240p30: + return 1_000_000 // 1 Mbps + case .sd144p30: + return 600_000 // 0.6 Mbps + } + } +} diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 05cf1844..2ded5852 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -424,18 +424,34 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable { case sd240p30 case sd144p30 - var value: Stream.Resolution! { - .init(rawValue: rawValue) + var value: Stream.Resolution { + if let predefined = Stream.Resolution.PredefinedResolution(rawValue: rawValue) { + return .predefined(predefined) + } + // Provide a default value of 720p 30 + return .custom(height: 720, refreshRate: 30) } var description: String { - switch self { - case .hd2160p60: - return "4K, 60fps" - case .hd2160p30: - return "4K" + let resolution = value + let height = resolution.height + let refreshRate = resolution.refreshRate + + // Superscript labels + let superscript4K = "⁴ᴷ" + let superscriptHD = "ᴴᴰ" + + // Special handling for specific resolutions + switch height { + case 2160: + // 4K superscript after the refresh rate + return refreshRate == 30 ? "2160p \(superscript4K)" : "2160p\(refreshRate) \(superscript4K)" + case 1440, 1080: + // HD superscript after the refresh rate + return refreshRate == 30 ? "\(height)p \(superscriptHD)" : "\(height)p\(refreshRate) \(superscriptHD)" default: - return value.name + // Default formatting for other resolutions + return refreshRate == 30 ? "\(height)p" : "\(height)p\(refreshRate)" } } } diff --git a/Shared/Settings/QualityProfileForm.swift b/Shared/Settings/QualityProfileForm.swift index 9bd7033b..c04e153b 100644 --- a/Shared/Settings/QualityProfileForm.swift +++ b/Shared/Settings/QualityProfileForm.swift @@ -315,7 +315,9 @@ struct QualityProfileForm: View { func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool { guard backend == .appleAVPlayer else { return false } - return resolution.value > .hd720p30 + let hd720p30 = Stream.Resolution.predefined(.hd720p30) + + return resolution.value > hd720p30 } func initializeForm() {