mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
9cb0325503
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>
413 lines
12 KiB
Swift
413 lines
12 KiB
Swift
import AVFoundation
|
|
import Defaults
|
|
import Foundation
|
|
|
|
// swiftlint:disable:next final_class
|
|
class Stream: Equatable, Hashable, Identifiable {
|
|
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, hd4320p30
|
|
|
|
// 4K UHD (16:9) Resolutions
|
|
case hd2160p60, hd2160p30
|
|
|
|
// 1440p (16:9) Resolutions
|
|
case hd1440p60, hd1440p30
|
|
|
|
// 1080p (Full HD, 16:9) Resolutions
|
|
case hd1080p60, hd1080p30
|
|
|
|
// 720p (HD, 16:9) Resolutions
|
|
case hd720p60, hd720p30
|
|
|
|
// Standard Definition (SD) Resolutions
|
|
case sd480p30
|
|
case sd360p30
|
|
case sd240p30
|
|
case sd144p30
|
|
}
|
|
|
|
var name: String {
|
|
switch self {
|
|
case let .predefined(predefined):
|
|
return predefined.rawValue
|
|
case let .custom(height, refreshRate):
|
|
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
|
}
|
|
}
|
|
|
|
var height: Int {
|
|
switch self {
|
|
case let .predefined(predefined):
|
|
return predefined.height
|
|
case let .custom(height, _):
|
|
return height
|
|
}
|
|
}
|
|
|
|
var refreshRate: Int {
|
|
switch self {
|
|
case let .predefined(predefined):
|
|
return predefined.refreshRate
|
|
case let .custom(_, refreshRate):
|
|
return refreshRate
|
|
}
|
|
}
|
|
|
|
var bitrate: Int {
|
|
switch self {
|
|
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 {
|
|
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 {
|
|
case hls, adaptive, stream
|
|
|
|
private var sortOrder: Int {
|
|
switch self {
|
|
case .hls:
|
|
return 0
|
|
case .stream:
|
|
return 1
|
|
case .adaptive:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
static func < (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.sortOrder < rhs.sortOrder
|
|
}
|
|
}
|
|
|
|
enum Format: String {
|
|
case avc1
|
|
case mp4
|
|
case av1
|
|
case webm
|
|
case hls
|
|
case stream
|
|
case unknown
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .webm:
|
|
return "WebM"
|
|
case .hls:
|
|
return "adaptive (HLS)"
|
|
case .stream:
|
|
return "Stream"
|
|
default:
|
|
return rawValue.uppercased()
|
|
}
|
|
}
|
|
|
|
static func from(_ string: String) -> Self {
|
|
let lowercased = string.lowercased()
|
|
|
|
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("webm") {
|
|
return .webm
|
|
}
|
|
if lowercased.contains("stream") {
|
|
return .stream
|
|
}
|
|
if lowercased.contains("hls") {
|
|
return .hls
|
|
}
|
|
return .unknown
|
|
}
|
|
}
|
|
|
|
let id = UUID()
|
|
|
|
var instance: Instance!
|
|
var audioAsset: AVURLAsset!
|
|
var videoAsset: AVURLAsset!
|
|
var hlsURL: URL!
|
|
var localURL: URL!
|
|
|
|
var resolution: Resolution!
|
|
var kind: Kind!
|
|
var format: Format!
|
|
|
|
var encoding: String?
|
|
var videoFormat: String?
|
|
var bitrate: Int?
|
|
var requestRange: String?
|
|
|
|
init(
|
|
instance: Instance? = nil,
|
|
audioAsset: AVURLAsset? = nil,
|
|
videoAsset: AVURLAsset? = nil,
|
|
hlsURL: URL? = nil,
|
|
localURL: URL? = nil,
|
|
resolution: Resolution? = nil,
|
|
kind: Kind = .hls,
|
|
encoding: String? = nil,
|
|
videoFormat: String? = nil,
|
|
bitrate: Int? = nil,
|
|
requestRange: String? = nil
|
|
) {
|
|
self.instance = instance
|
|
self.audioAsset = audioAsset
|
|
self.videoAsset = videoAsset
|
|
self.hlsURL = hlsURL
|
|
self.localURL = localURL
|
|
self.resolution = resolution
|
|
self.kind = kind
|
|
self.encoding = encoding
|
|
format = .from(videoFormat ?? "")
|
|
self.bitrate = bitrate
|
|
self.requestRange = requestRange
|
|
}
|
|
|
|
var isLocal: Bool {
|
|
localURL != nil
|
|
}
|
|
|
|
var isHLS: Bool {
|
|
hlsURL != nil
|
|
}
|
|
|
|
var quality: String {
|
|
guard localURL.isNil else { return "Opened File" }
|
|
|
|
if kind == .hls {
|
|
return "adaptive (HLS)"
|
|
}
|
|
|
|
return resolution.name
|
|
}
|
|
|
|
var shortQuality: String {
|
|
guard localURL.isNil else { return "File" }
|
|
|
|
if kind == .hls {
|
|
return "adaptive (HLS)"
|
|
}
|
|
|
|
if kind == .stream {
|
|
return resolution.name
|
|
}
|
|
return resolutionAndFormat
|
|
}
|
|
|
|
var description: String {
|
|
guard localURL.isNil else { return resolutionAndFormat }
|
|
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
|
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
|
}
|
|
|
|
var resolutionAndFormat: String {
|
|
let formatString = format == .unknown ? "" : " (\(format.description))"
|
|
return "\(quality)\(formatString)"
|
|
}
|
|
|
|
var assets: [AVURLAsset] {
|
|
[audioAsset, videoAsset]
|
|
}
|
|
|
|
var videoAssetContainsAudio: Bool {
|
|
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
|
|
}
|
|
|
|
var singleAssetURL: URL? {
|
|
guard localURL.isNil else {
|
|
return URLBookmarkModel.shared.loadBookmark(localURL) ?? localURL
|
|
}
|
|
|
|
if kind == .hls {
|
|
return hlsURL
|
|
}
|
|
if videoAssetContainsAudio {
|
|
return videoAsset.url
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
static func == (lhs: Stream, rhs: Stream) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
if let url = videoAsset?.url {
|
|
hasher.combine(url)
|
|
}
|
|
if let url = audioAsset?.url {
|
|
hasher.combine(url)
|
|
}
|
|
if let url = hlsURL {
|
|
hasher.combine(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|