Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an einer Stelle, `PlaybackDecider.fallback`. Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket. **Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt, sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind` fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy` kennt kein `allowsFallbackEngine`. **Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern, ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer nicht an. Statt der zweiten Engine rechnet der Server jetzt neu (`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer schwarzen Wand mit einer Fehlernummer. **Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird, kommt schon in einer Hülle, die der Systemplayer öffnet. Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten: Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten auf dem Apple TV, wo es VLC ohnehin nie gab. Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist geblieben, sein Ziel nicht. iOS und tvOS bauen, 494 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.8 KiB
Swift
209 lines
7.8 KiB
Swift
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]
|
|
}
|
|
}
|