import Foundation /// Eine im Player wählbare Qualitätsstufe. /// /// Getrennt von den Einstellungen unter „Qualität": dort steht, was /// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil /// es gerade ruckelt oder weil man ausnahmsweise das Original will. public enum PlayerQuality: Hashable, Sendable, Identifiable { /// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist. case automatic /// Die Datei unverändert, ohne Umrechnung. case original /// Eine Stufe der Leiter, die der Server anbietet. case limited(Rung) /// So wenig Bild wie möglich. case audioOnly /// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht. /// /// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server /// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter /// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht /// kennt — er rundet dann still auf die nächste, und die Anzeige log. public struct Rung: Hashable, Sendable { public let bitrate: Int public let height: Int public init(bitrate: Int, height: Int) { self.bitrate = bitrate self.height = height } public var width: Int { // 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag // kein Kodierer. let raw = Int((Double(height) * 16.0 / 9.0).rounded()) return raw % 2 == 0 ? raw : raw + 1 } public var resolution: String { "\(width)x\(height)" } } /// Die Leiter von oben nach unten. public static let ladder: [Rung] = [ Rung(bitrate: 20_000, height: 1080), Rung(bitrate: 12_000, height: 1080), Rung(bitrate: 10_000, height: 1080), Rung(bitrate: 8_000, height: 1080), Rung(bitrate: 4_000, height: 1080), Rung(bitrate: 3_000, height: 720), Rung(bitrate: 2_000, height: 720), Rung(bitrate: 1_500, height: 720), Rung(bitrate: 720, height: 480), Rung(bitrate: 320, height: 360), Rung(bitrate: 208, height: 360), Rung(bitrate: 96, height: 240), Rung(bitrate: 64, height: 160), ] /// Eine Zahl, die sich aufbewahren lässt. /// /// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel /// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt /// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert. public var storageValue: Int { switch self { case .automatic: 0 case .original: -1 case .audioOnly: -2 case .limited(let rung): rung.bitrate } } /// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich. /// /// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene /// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann /// stillschweigend auf „automatisch" zurückzufallen wäre eine andere /// Antwort als die, um die gebeten wurde. public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? { switch value { case 0: return nil case -1: return .original case -2: return .audioOnly default: break } let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in if case .limited(let rung) = quality { return rung } return nil } guard let closest = rungs.min(by: { abs($0.bitrate - value) < abs($1.bitrate - value) }) else { return nil } return .limited(closest) } public static var allCases: [PlayerQuality] { [.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly] } public var id: String { switch self { case .automatic: "automatic" case .original: "original" case .audioOnly: "audioOnly" case .limited(let rung): "limited-\(rung.bitrate)" } } /// Obergrenze in Kilobit. `nil` bei `automatic` und `original`. public var maximumBitrate: Int? { switch self { case .automatic, .original: nil case .limited(let rung): rung.bitrate // Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse // ist das, was dem am nächsten kommt; das Bild wird dazu im Player // ausgeblendet, damit der Schirm dunkel bleiben kann. case .audioOnly: Self.ladder.last?.bitrate } } public var resolution: String? { switch self { case .automatic, .original: nil case .limited(let rung): rung.resolution case .audioOnly: Self.ladder.last?.resolution } } /// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt. public var height: Int? { switch self { case .automatic, .original: nil case .limited(let rung): rung.height case .audioOnly: Self.ladder.last?.height } } /// Ob das Bild gezeigt werden soll. public var showsVideo: Bool { if case .audioOnly = self { return false } return true } /// Ob diese Stufe eine Umrechnung erzwingt. /// /// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die /// Einstellungen eine Grenze vorsehen. public var forcesTranscode: Bool { switch self { case .automatic, .original: false case .limited, .audioOnly: true } } /// Die Richtlinie für diese Stufe. /// /// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben /// sie — das ist der Zweck eines Eingriffs im Player. public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy { switch self { case .automatic: base case .original: // Keine Grenze und kein Umrechnen: ausdrücklich das Original. PlaybackPolicy(allowsTranscoding: false, maximumBitrate: nil) case .limited, .audioOnly: PlaybackPolicy( allowsTranscoding: true, maximumBitrate: maximumBitrate, forcesTranscoding: true ) } } /// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare. /// /// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht /// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl /// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt /// erreichbar, steht aber nicht im Weg. public static let everyday: [Rung] = [ Rung(bitrate: 8_000, height: 1080), Rung(bitrate: 3_000, height: 720), Rung(bitrate: 720, height: 480), ] /// Die kurze Auswahl für eine Quelle dieser Höhe. public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] { let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) } let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly] } /// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind. /// /// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und /// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen. public static func available(forSourceHeight height: Int?) -> [PlayerQuality] { guard let height, height > 0 else { return allCases } let fitting = ladder.filter { $0.height <= height } // Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer // sehr kleinen Quelle nur „Original". let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly] } }