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>
252 lines
9.7 KiB
Swift
252 lines
9.7 KiB
Swift
import Foundation
|
||
|
||
/// Woher das Bild kommt.
|
||
public struct PlaybackSource: Sendable, Equatable {
|
||
public enum Origin: Sendable, Equatable {
|
||
/// Vom Server — per DirectPlay oder als HLS-Stream des Transcoders.
|
||
case remote
|
||
/// Aus dem Download-Ordner.
|
||
case local
|
||
}
|
||
|
||
public let url: URL
|
||
public let origin: Origin
|
||
/// Ab wo gestartet werden soll. Kommt aus dem zuletzt gemeldeten
|
||
/// Fortschritt; `.zero` heißt von vorn.
|
||
public let startTime: Duration
|
||
/// Wie viel Vorrat der Player anlegen soll.
|
||
///
|
||
/// `nil` überlässt es dem System. Das ist für eine Datei auf der Platte
|
||
/// richtig und für einen Sender richtig — bei einem Stream über eine
|
||
/// wackelige Leitung ist es zu wenig: der Systemwert ist auf schnellen
|
||
/// Start ausgelegt, nicht darauf, einen Aussetzer zu überstehen.
|
||
///
|
||
/// **Wer das entscheidet, muss mehr wissen als die Engine.** Ob es ein
|
||
/// Sender ist, ob die Datei lokal liegt — das weiß der Player, nicht die
|
||
/// Engine. Deshalb reist die Zahl mit der Quelle, statt dass die Engine
|
||
/// rät.
|
||
public let forwardBuffer: Duration?
|
||
|
||
public init(
|
||
url: URL,
|
||
origin: Origin,
|
||
startTime: Duration = .zero,
|
||
forwardBuffer: Duration? = nil
|
||
) {
|
||
self.url = url
|
||
self.origin = origin
|
||
self.startTime = startTime
|
||
self.forwardBuffer = forwardBuffer
|
||
}
|
||
}
|
||
|
||
/// Was eine Engine von sich aus meldet.
|
||
///
|
||
/// Ein Stream statt beobachtbarer Eigenschaften: der Player meldet über
|
||
/// KVO, und das in eine Ereignisfolge zu übersetzen hält den Unterschied an
|
||
/// einer Stelle — statt in jeder View, die den Zustand anzeigt.
|
||
public enum PlaybackEvent: Sendable, Equatable {
|
||
case stateChanged(PlaybackState)
|
||
case timeChanged(Duration)
|
||
case durationResolved(Duration)
|
||
case tracksResolved(audio: [MediaTrack], subtitle: [MediaTrack])
|
||
case reachedEnd
|
||
}
|
||
|
||
/// Wo das Bild aufhört.
|
||
///
|
||
/// **Hier und nicht in der App**, weil es beide Engines angeht. Am Bild
|
||
/// einzupassen ist nur zur Hälfte Sache der Ansicht: sie bestimmt die Fläche,
|
||
/// die Engine bestimmt, wie das Bild darin liegt.
|
||
public enum VideoFit: Sendable {
|
||
/// Ganz sichtbar, mit schwarzen Balken.
|
||
case fit
|
||
/// Bildfüllend, oben und unten beschnitten.
|
||
case fill
|
||
}
|
||
|
||
/// Was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
|
||
///
|
||
/// **Ein Protokoll für einen Spieler.** Es gab einmal zwei, und der Schnitt
|
||
/// ist geblieben: er hält die Oberfläche von AVFoundation fern, und was hier
|
||
/// nicht steht, gibt es für die Views nicht.
|
||
@MainActor
|
||
public protocol VideoPlayerEngine: AnyObject {
|
||
var events: AsyncStream<PlaybackEvent> { get }
|
||
|
||
var state: PlaybackState { get }
|
||
var currentTime: Duration { get }
|
||
var duration: Duration? { get }
|
||
|
||
/// Ob dieser Engine Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
|
||
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
|
||
/// schlimmer als keiner.
|
||
var supportsPictureInPicture: Bool { get }
|
||
|
||
/// Ob dieser Engine sein Bild an ein AirPlay-Gerät geben kann.
|
||
///
|
||
/// Ein Knopf dafür darf nur stehen, wo er auch etwas trifft.
|
||
var supportsAirPlay: Bool { get }
|
||
|
||
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
|
||
func play()
|
||
func pause()
|
||
func seek(to time: Duration) async
|
||
func setRate(_ rate: Double)
|
||
|
||
var audioTracks: [MediaTrack] { get }
|
||
var subtitleTracks: [MediaTrack] { get }
|
||
func selectAudioTrack(_ track: MediaTrack?)
|
||
/// `nil` schaltet die Untertitel ab.
|
||
func selectSubtitleTrack(_ track: MediaTrack?)
|
||
|
||
/// Wie Untertitel dargestellt werden sollen.
|
||
///
|
||
/// **Darf jederzeit gesetzt werden, auch mitten im Film.** Genau deshalb
|
||
/// steht es hier und nicht an `PlaybackSource`: die wird einmal beim
|
||
/// Öffnen gelesen, und eine Einstellung, die erst beim nächsten Film
|
||
/// wirkt, hält jeder für kaputt.
|
||
func applySubtitleStyle(_ style: SubtitleStyle)
|
||
|
||
/// Was gerade tatsächlich über die Leitung kommt.
|
||
///
|
||
/// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der
|
||
/// Player selbst, welche Stufe er zieht, und die kann von der gewählten
|
||
/// Obergrenze abweichen. Ohne diese Angabe bleibt „ruckelt es, weil die
|
||
/// Leitung schlecht ist?" eine Vermutung.
|
||
var statistics: PlaybackStatistics? { get }
|
||
|
||
/// Deckelt, welche Sprosse der Player höchstens ziehen darf.
|
||
///
|
||
/// **Der billige Hebel.** Er wirkt ohne Neuaufbau: kein Anhalten am
|
||
/// Server, keine neue Aushandlung, kein Sprung zurück an die alte Stelle.
|
||
/// `nil` hebt die Grenze auf.
|
||
///
|
||
/// Zwei Einschränkungen gehören dazugesagt. Der Wert greift erst an der
|
||
/// nächsten Schnipselgrenze, also mit einigen Sekunden Verzug — ein Grund
|
||
/// mehr, nicht bei jedem Ausschlag daran zu drehen. Und **bei einer
|
||
/// Playlist mit einer einzigen Stufe tut er nichts**: der Player spielt
|
||
/// sie auch dann, wenn sie über der Grenze liegt. Der Wert ist ein
|
||
/// Wunsch, keine Vorschrift.
|
||
func setPeakBitrate(_ kilobits: Int?)
|
||
|
||
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen.
|
||
func teardown()
|
||
}
|
||
|
||
|
||
/// Was über die Leitung kommt, während gespielt wird.
|
||
public struct PlaybackStatistics: Sendable, Equatable {
|
||
/// Was tatsächlich ankommt, in Kilobit je Sekunde.
|
||
///
|
||
/// **Nachlaufend und leicht misszuverstehen.** Spielt der Player
|
||
/// zufrieden eine kleine Sprosse, holt er wenig — und dieser Wert sieht
|
||
/// schlecht aus, ohne dass die Leitung schlecht wäre. Als alleiniges
|
||
/// Kriterium für eine Regelung taugt er deshalb nicht.
|
||
public let observedBitrate: Int?
|
||
/// Was der Stream für sich angibt — also auf welcher Sprosse man steht.
|
||
public let indicatedBitrate: Int?
|
||
public let width: Int?
|
||
public let height: Int?
|
||
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
|
||
public let droppedFrames: Int?
|
||
|
||
/// Wie viele Stufen die Playlist überhaupt anbietet.
|
||
///
|
||
/// **Die wichtigste Zahl hier**, und die einzige, die die Frage
|
||
/// beantwortet, ob es etwas zum Umschalten gibt. Sie kommt vom Parser des
|
||
/// Players, nicht aus einer Vermutung über den Server: `1` heißt, dass
|
||
/// alles Nachregeln ins Leere greift, ganz gleich was bestellt wurde.
|
||
///
|
||
/// `nil` bei allem, was keine Playlist ist — eine Datei hat keine Leiter,
|
||
/// und das ist die richtige Aussage, nicht „null Stufen".
|
||
public let variantCount: Int?
|
||
/// Die Sprossen selbst, aufsteigend, in Kilobit je Sekunde.
|
||
///
|
||
/// Nötig, um eine Stufe tiefer zu **zielen**: ein Wert zwischen zwei
|
||
/// Sprossen wählt dieselbe wie die untere und macht jede Anzeige darüber
|
||
/// zur Lüge.
|
||
public let variantBitrates: [Int]
|
||
|
||
/// Wie weit der Puffer vor der Abspielstelle reicht.
|
||
///
|
||
/// **Der einzige echte Frühwarner.** Schrumpft er schneller, als die Zeit
|
||
/// vergeht, kommt weniger herein als hinausgeht — und das ist zehn bis
|
||
/// zwanzig Sekunden vor dem Rückler zu sehen. Alles andere meldet sich
|
||
/// erst, wenn das Bild schon steht.
|
||
public let bufferedAhead: Duration?
|
||
/// Apples eigene Einschätzung. Früh, aber grob.
|
||
public let isLikelyToKeepUp: Bool?
|
||
|
||
/// Wie oft es seit dem Start gestockt hat.
|
||
public let stallCount: Int
|
||
/// Wie lange insgesamt gewartet wurde.
|
||
public let stalledTime: Duration
|
||
|
||
public init(
|
||
observedBitrate: Int? = nil,
|
||
indicatedBitrate: Int? = nil,
|
||
width: Int? = nil,
|
||
height: Int? = nil,
|
||
droppedFrames: Int? = nil,
|
||
variantCount: Int? = nil,
|
||
variantBitrates: [Int] = [],
|
||
bufferedAhead: Duration? = nil,
|
||
isLikelyToKeepUp: Bool? = nil,
|
||
stallCount: Int = 0,
|
||
stalledTime: Duration = .zero
|
||
) {
|
||
self.observedBitrate = observedBitrate
|
||
self.indicatedBitrate = indicatedBitrate
|
||
self.width = width
|
||
self.height = height
|
||
self.droppedFrames = droppedFrames
|
||
self.variantCount = variantCount
|
||
self.variantBitrates = variantBitrates
|
||
self.bufferedAhead = bufferedAhead
|
||
self.isLikelyToKeepUp = isLikelyToKeepUp
|
||
self.stallCount = stallCount
|
||
self.stalledTime = stalledTime
|
||
}
|
||
|
||
public var resolution: String? {
|
||
guard let width, let height, width > 0, height > 0 else { return nil }
|
||
return "\(width)×\(height)"
|
||
}
|
||
|
||
/// Ob es überhaupt etwas zum Umschalten gibt.
|
||
///
|
||
/// Bei `false` ist `preferredPeakBitRate` wirkungslos — AVPlayer spielt
|
||
/// die eine Stufe auch dann, wenn sie über der Grenze liegt. Genau dafür
|
||
/// gibt es die Sicherheitsleine.
|
||
public var hasLadder: Bool { (variantCount ?? 0) > 1 }
|
||
|
||
/// Die nächste Sprosse unterhalb der angegebenen — oder die unterste.
|
||
///
|
||
/// Gibt `nil` zurück, wenn es nichts Tieferes gibt. Dann ist der billige
|
||
/// Weg erschöpft.
|
||
public func rung(below bitrate: Int) -> Int? {
|
||
variantBitrates.last { $0 < bitrate }
|
||
}
|
||
}
|
||
|
||
extension VideoPlayerEngine {
|
||
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
|
||
public var statistics: PlaybackStatistics? { nil }
|
||
|
||
/// Wer keine Leiter hat, kann auch nichts deckeln. Kein Fehler, nur
|
||
/// nichts zu tun — bei einer Datei gibt es keine Stufen.
|
||
public func setPeakBitrate(_ kilobits: Int?) {}
|
||
|
||
/// Wer Untertitel nicht gestalten kann, lässt sie, wie sie sind.
|
||
///
|
||
public func applySubtitleStyle(_ style: SubtitleStyle) {}
|
||
|
||
/// Wer nichts dazu sagt, kann es nicht.
|
||
public var supportsAirPlay: Bool { false }
|
||
|
||
/// Der Systemplayer regelt es über seine Bildebene, und die hält die App —
|
||
/// siehe `PlayerModel.applyVideoFit()`. Hier ist nichts zu tun.
|
||
public func setVideoFit(_ fit: VideoFit) {}
|
||
}
|