Zwei Fehler, die dieselbe Ursache haben: alles, was über VLC läuft, ging
an der Bildebene vorbei, an der die App gedreht hat.
**Das Bild sass nicht mittig.** Nicht ein paar Punkte daneben — gemessen
eine Anzeigefläche von 1200×480 Punkt in einer Zeichenfläche von 711×400,
unten rechts angeschlagen, im falschen Seitenverhältnis. Der Grund steht
im Vertrag von `VLCDrawable`: VLCKit fragt die Grösse **einmal** beim
Anlegen ab und überlässt seine Anzeigefläche danach der
Autoresizing-Maske. Eine Maske hält aber den Unterschied fest, nicht das
Verhältnis — was beim Anlegen zu gross war, bleibt für immer um denselben
Betrag zu gross. Und angelegt wird sie mitten in der Drehung ins
Querformat, während SwiftUI sie einblendet: also regelmässig falsch.
Die Zeichenfläche ist jetzt eine eigene Klasse, die ihrer Anzeigefläche
bei jedem Layout wieder die eigene Grösse gibt. Danach gemessen: 695,7 ×
400 Punkt, Rand links 130,0, rechts 130,3 — mittig.
**Der Knopf für einpassen/füllen tat nichts.** Er schrieb `videoGravity`
auf eine `AVPlayerLayer`, die bei VLC gar nicht im Baum hängt. Die
Einstellung geht deshalb jetzt durch die Engine: `setVideoFit` am
Protokoll, beim Systemplayer eine Voreinstellung, die nichts tut — dort
macht es die Bildebene —, bei VLC `videoFitMode`. `smaller` heisst bei
libvlc „fit inside", `larger` „fit outside".
Gemessen, im Querformat auf einem Pro Max, über VLC:
eingepasst 695,7 × 400 pt Rand l/r 130,0/130,3 o/u 20,0/20,0
füllend 935,3 × 440 pt Rand l/r 10,3/ 10,3 o/u 0,0/ 0,0
**Zurückgenommen:** im Kommentar an `videoFit` stand, ein `didSet` auf
einer `@Observable`-Klasse werde nicht gemeldet. Das stimmt nicht —
`HomeLayoutSettings` lebt davon. Ein Kommentar, der einen falschen
Mechanismus behauptet, schickt den nächsten in die falsche Richtung;
jetzt steht der belegte Grund dort.
Dazu, damit dieser Weg überhaupt prüfbar ist: die Attrappe meldet mit
`--vlc` DTS-Ton. AVFoundation kann das nicht, also fällt die App auf
VLCKit zurück. Die Beispieldatei bleibt dieselbe — geprüft wird der Weg,
nicht der Ton. Ohne diesen Schalter ist der VLC-Zweig im Simulator gar
nicht zu erreichen, und genau deshalb lagen beide Fehler so lange da.
Ebenfalls dabei: `EdgeInsets.symmetrical` mit fünf Tests und das Polster
im Player, das damit nicht mehr verschiebt statt einrückt.
iOS und tvOS bauen, 465 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
11 KiB
Swift
270 lines
11 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
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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)
|
||
|
||
/// Wie das Bild in seiner Fläche liegt.
|
||
///
|
||
/// **Muss durch die Engine, nicht nur über die Fläche.** Wer die Fläche
|
||
/// auf den ganzen Schirm wachsen lässt, hat damit erst die Hälfte gesagt:
|
||
/// VLC zeichnet selbst und passt sein Bild von sich aus ein, also wird
|
||
/// der Balken daneben nur breiter. Beschneiden muss man ihm sagen.
|
||
///
|
||
/// Der Systemplayer braucht es nicht — seine Bildebene gehört der App,
|
||
/// und die stellt `videoGravity` selbst. Deshalb steht unten eine
|
||
/// Voreinstellung, die nichts tut.
|
||
func setVideoFit(_ fit: VideoFit)
|
||
|
||
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,
|
||
/// 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 Untertitel nicht gestalten kann, lässt sie, wie sie sind.
|
||
///
|
||
/// VLC kann es im Grundsatz, aber nur über Angaben, die beim Bau der
|
||
/// Bibliothek gesetzt werden, global gelten und sich mitten im Film nicht
|
||
/// ändern lassen — ein zweiter Weg, der vom ersten abdriftet.
|
||
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) {}
|
||
}
|