more robust resolution handling

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 <toni.foerster@gmail.com>
This commit is contained in:
Toni Förster 2024-09-09 12:59:39 +02:00
parent b0264aaabe
commit 9cb0325503
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
7 changed files with 222 additions and 273 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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)
enum PredefinedResolution: String, CaseIterable, Codable {
// 8K UHD (16:9) Resolutions
case hd4320p60
case hd4320p50
case hd4320p48
case hd4320p30
case hd4320p25
case hd4320p24
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
// 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
// 720p (HD, 16:9) Resolutions
case hd720p60, hd720p30
// 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
}
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
}
}
}

View File

@ -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)"
}
}
}

View File

@ -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() {