Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift
T
ScarriffleandClaude Opus 5 baa542690c Vorrat anlegen, und VLC meldet endlich das Stocken
Zwei Änderungen, die an der Wiedergabe selbst etwas tun — beide ohne
Abhängigkeit davon, ob die Server je eine Leiter liefern.

**Vorrat.** Ohne Angabe legt AVPlayer so viel an, wie es für einen
zügigen Start braucht, und keinen Meter mehr. Das ist richtig, solange
die Leitung trägt, und falsch, sobald sie aussetzt: ein Loch von fünf
Sekunden trifft dann auf einen Puffer von fünf Sekunden, und das Bild
steht. Fünfundzwanzig Sekunden sind ein Anfangswert, kein Messergebnis.

Nur für Filme und Folgen über das Netz. **Nicht für Sender** — dort
hieße ein halbminütiger Vorrat eine halbe Minute Verzug und ein zähes
Umschalten. Und nicht für Dateien von der Platte, die brauchen keinen.
Wer das entscheidet, muss mehr wissen als der Motor, deshalb reist die
Zahl mit der Quelle statt dass die Engine rät.

**VLC hat das Stocken nie gemeldet.** `VLCMediaPlayerState` kennt keinen
Wert dafür — die Aufzählung führt nur `opening`, `playing`, `paused`,
`stopped`, `error`. Der Puffer-Fortschritt kommt über
`mediaPlayerBufferingChanged:`, und die Methode fehlte. Folge:
`.buffering` ging dort **nie** an, der Wachhund im Player löste nie aus,
und ein hängender Strom lief stumm ins Nichts — ohne Kreisel, ohne
Meldung, ohne Abbruch. Ausgerechnet auf dem Weg, der die großen Remuxe
spielt, bei denen eine schwache Leitung am ehesten scheitert.

Gewertet wird nur aus dem Spielen heraus: laut Dokumentation kommt vor
jeder Wiedergabe einmal 0,0 und einmal 1,0, und dieses anfängliche
Füllen ist der Start, kein Rückler.

Dazu meldet VLC jetzt überhaupt Statistik. `variantCount` bleibt dabei
`nil` und nicht `1` — eine Datei hat keine Leiter, und „gibt es nicht"
ist etwas anderes als „gibt es einmal". Bei der Bitrate steht die
Annahme im Kommentar statt im Nebel: libvlc führt `inputBitrate`
vermutlich in Byte je Sekunde, daher der Faktor acht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 15:06:36 +02:00

209 lines
7.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Strom ü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 der Motor.** 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 }
/// 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 }
}