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