From 9cb03255037fffbacd62a5cb2da9e984907623b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Mon, 9 Sep 2024 12:59:39 +0200 Subject: [PATCH] more robust resolution handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, we have a hard-coded list of resolutions. Since Invidious reports the actual resolution of a stream and does not hard-code them to a fixed value anymore, resolutions that are not in the list won’t be handled, and the stream cannot be played back. Instead of hard-coding even more resolutions (and inadvertently might not cover all), we revert the list back to a finite set of resolutions, the users can select from. All other resolutions are handled dynamically and compared to the existing set of defined resolutions when selecting the best stream for playback. Signed-off-by: Toni Förster --- Model/Applications/PeerTubeAPI.swift | 3 +- Model/Player/Backends/MPVBackend.swift | 2 +- Model/Player/Backends/PlayerBackend.swift | 9 +- Model/QualityProfile.swift | 3 +- Model/Stream.swift | 442 +++++++++------------- Shared/Defaults.swift | 32 +- Shared/Settings/QualityProfileForm.swift | 4 +- 7 files changed, 222 insertions(+), 273 deletions(-) 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() {