Der Fehlstart im Zug hat den eigentlichen Mangel gezeigt: die ganze neue Regelung misst **ab dem ersten Bild**. Kommt nie eines, misst sie nichts, greift nichts — und „Auto" hat sich als das erwiesen, was es wörtlich heisst: „nimm, was in den Einstellungen steht". Steht dort „Original", bittet die App über eine Zugverbindung um eine 4K-Datei und wartet vierzig Sekunden auf etwas, das nie kommen kann. Statt aufzugeben geht der Watchdog jetzt eine Sprosse tiefer und baut neu auf — bei Filmen über `restart()`, bei Sendern über den Tuner-Abbau. Gezählt gegen dasselbe Budget wie die Sicherheitsleine, sonst schickt eine tote Leitung die App endlos im Kreis. Scheitert auch das, nennt die Meldung jetzt den Grund und den Weg statt nur „keine Daten mehr": wer ohne Grenze angefragt hat, soll erfahren, dass es daran liegt und wo es sich ändern lässt. Und „Strom" heisst Stream. Zweiunddreissig Stellen, dazu das Gedächtnis ergänzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
8.8 KiB
Swift
227 lines
8.8 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: AVPlayer meldet über KVO,
|
||
/// VLCKit über Delegat-Rückrufe. Beide in dieselbe Ereignisfolge zu
|
||
/// übersetzen hält den Unterschied an genau einer Stelle je Engine — 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
|
||
}
|
||
|
||
/// Der gemeinsame Nenner von AVPlayer und VLCKit.
|
||
///
|
||
/// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
|
||
/// Was hier nicht steht, gibt es für die Views nicht; dadurch bleibt der
|
||
/// Austausch des Engine unsichtbar.
|
||
@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.
|
||
///
|
||
/// Nur der Systemplayer kann das. VLC zeichnet selbst und kennt die
|
||
/// Empfängerliste nicht — ein Knopf dafür führte dort ins Leere.
|
||
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?)
|
||
|
||
/// 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,
|
||
/// sonst behält besonders VLCKit den Speicher.
|
||
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 nichts dazu sagt, kann es nicht.
|
||
public var supportsAirPlay: Bool { false }
|
||
}
|