Bisher gab es über eine laufende Wiedergabe zwei Zahlen — gemessene Bitrate und verworfene Bilder —, und die standen nur zur Anzeige da. Die Frage „trägt die Leitung das gerade" war nicht zu beantworten, weder für den Nutzer noch für die App. **Die wichtigste neue Zahl ist `variantCount`.** Sie kommt aus `AVAsset.variants`, also vom Parser des Players, und beantwortet die Frage, ob es überhaupt etwas zum Umschalten gibt. Steht dort 1, greift jedes Nachregeln ins Leere — und genau das ist heute der Fall, weil die App bei beiden Servern eine Playlist mit einer Stufe bestellt. Dazu der Pufferstand aus `loadedTimeRanges`: der einzige echte Frühwarner. Schrumpft er schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht — zehn bis zwanzig Sekunden, bevor das Bild steht. Alles andere meldet sich erst danach. **Verworfene Bilder werden jetzt über alle Protokolleinträge summiert.** Das Zugriffsprotokoll legt bei jedem Stufenwechsel einen neuen an, dessen Zähler bei null beginnt; wer nur `last` liest, hält eine Wiedergabe für makellos, die schon zwanzigmal Bilder verworfen hat. **Der Wachhund war blind für den häufigsten Fall.** Vierzig Sekunden am Stück ohne Daten brechen ab — aber er setzte sich bei jedem kurzen Erholen zurück, und zehn Rückler à dreißig Sekunden lösten ihn nie aus. `StallLedger` zählt daneben mit, wie viel Zeit im gleitenden Fenster fürs Warten draufging. Es bricht nichts ab, es stellt nur fest — und ist ein reiner Typ ohne Uhr und ohne AVFoundation, damit sich eine Folge von Rücklern als Zahlenreihe hinschreiben und das Urteil prüfen lässt. Sieben Tests, darunter genau der Fall, an dem der alte Wachhund vorbeisah. Gezählt wird nur der Weg vom Spielen ins Warten. Das anfängliche Puffern ist der Start, kein Rückler; würde es mitgezählt, hätte jede Wiedergabe eine Vorstrafe. Und zwei Kommentare sind berichtigt. Sie behaupteten, AVPlayer wechsle die Bitrate von selbst und das sei „die zugesagte automatische Qualitätsanpassung". Umschalten kann er nur zwischen Varianten, die in der Liste stehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.1 KiB
Swift
191 lines
7.1 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
|
||
|
||
public init(url: URL, origin: Origin, startTime: Duration = .zero) {
|
||
self.url = url
|
||
self.origin = origin
|
||
self.startTime = startTime
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
|
||
/// 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 nichts dazu sagt, kann es nicht.
|
||
public var supportsAirPlay: Bool { false }
|
||
}
|