Bei VLC sass das Bild nie richtig — und der Knopf wirkte nie

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>
This commit is contained in:
Scarriffle
2026-09-30 21:05:57 +02:00
co-authored by Claude Opus 5
parent 51abcabb27
commit b0db4f8f5f
8 changed files with 229 additions and 15 deletions
+33 -12
View File
@@ -173,9 +173,20 @@ final class PlayerModel {
/// Bildebene selbst: der Wechsel zwischen Kachel und Vollbild und jeder /// Bildebene selbst: der Wechsel zwischen Kachel und Vollbild und jeder
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort, /// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst". /// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
var videoFit: VideoFit = .fit { ///
didSet { applyVideoGravity() } /// **Warum der Knopf lange nichts tat, und warum der Grund nicht der
} /// `didSet` war, der hier stand.** Ein `didSet` auf einer verfolgten
/// Eigenschaft einer `@Observable`-Klasse wird durchaus gemeldet —
/// `HomeLayoutSettings` lebt davon. Der Knopf schrieb `videoGravity` auf
/// `surface.playerLayer`, und diese Ebene hängt bei einer Datei, die über
/// VLC läuft, überhaupt nicht im Baum: `VideoSurface` nimmt dort
/// `vlcEngine.videoView`, und `attachSurface` steigt für alles ausser
/// AVPlayer gleich wieder aus. Geschrieben wurde also auf eine Ebene, die
/// niemand sieht.
///
/// Deshalb geht die Einstellung jetzt durch die Engine — siehe
/// `applyVideoFit()`.
var videoFit: VideoFit = .fit
/// Wie viel unten ausgespart bleibt, damit Untertitel sichtbar sind. /// Wie viel unten ausgespart bleibt, damit Untertitel sichtbar sind.
/// ///
@@ -187,12 +198,11 @@ final class PlayerModel {
var subtitleBottomInset: CGFloat = 0 var subtitleBottomInset: CGFloat = 0
/// Wo das Bild aufhört. /// Wo das Bild aufhört.
public enum VideoFit: Sendable { ///
/// Ganz sichtbar, mit schwarzen Balken. /// Liegt bei `FlixyPlayback`, weil beide Engines es brauchen. Der Name
case fit /// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
/// Bildfüllend, oben und unten beschnitten. /// kann.
case fill typealias VideoFit = FlixyPlayback.VideoFit
}
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit. /// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
/// ///
@@ -768,6 +778,10 @@ final class PlayerModel {
let engine = makeEngine(for: decision) let engine = makeEngine(for: decision)
self.engine = engine self.engine = engine
attachSurface(to: engine) attachSurface(to: engine)
// **Auch für VLC**, und deshalb hier und nicht in `attachSurface`:
// das steigt für alles ausser AVPlayer sofort aus, weil es die
// Bildebene anhängt. Die Skalierung geht aber beide an.
engine.setVideoFit(videoFit)
observe(engine) observe(engine)
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist // **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe. // ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
@@ -1716,12 +1730,19 @@ final class PlayerModel {
} }
/// Legt die gewählte Skalierung auf die Bildebene. /// Legt die gewählte Skalierung auf das Bild.
/// ///
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers /// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun. /// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
private func applyVideoGravity() { ///
/// **Zwei Zeilen, weil es zwei Engines gibt.** Die Bildebene gehört der
/// App und nicht dem Systemplayer — deshalb steht `videoGravity` hier und
/// nicht in `AVPlayerEngine`. VLC zeichnet dagegen selbst und passt sein
/// Bild von sich aus ein; ihm muss man es sagen. Solange nur die erste
/// Zeile hier stand, war der Knopf bei jeder Datei über VLC ohne Wirkung.
func applyVideoFit() {
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
engine?.setVideoFit(videoFit)
} }
/// Hängt die gehaltene Bildebene an den aktuellen Engine. /// Hängt die gehaltene Bildebene an den aktuellen Engine.
@@ -1737,7 +1758,7 @@ final class PlayerModel {
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut // `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
// läuft, fiel ein auf formatfüllend gestelltes Bild bei jedem // läuft, fiel ein auf formatfüllend gestelltes Bild bei jedem
// Qualitätswechsel klammheimlich zurück. // Qualitätswechsel klammheimlich zurück.
applyVideoGravity() applyVideoFit()
guard AVPictureInPictureController.isPictureInPictureSupported() else { return } guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
if pictureInPicture == nil { if pictureInPicture == nil {
+14 -1
View File
@@ -848,6 +848,10 @@ struct PlayerView: View {
// **Nur im Füllmodus.** Eingepasst liegt das Bild seit // **Nur im Füllmodus.** Eingepasst liegt das Bild seit
// Neuestem ohnehin im sicheren Bereich — dort kann kein // Neuestem ohnehin im sicheren Bereich — dort kann kein
// Untertitel mehr unter die Kante geraten. // Untertitel mehr unter die Kante geraten.
// Im Füllmodus läuft das Bild bis an die Kanten; damit der
// Untertitel nicht darunter gerät, wird die **Ebene** unten
// eingerückt. Der rohe Wert genügt hier — es geht um die
// Bildschirmkante, nicht um die Mitte.
bottomInset: model.videoFit == .fill ? safeArea.bottom : 0 bottomInset: model.videoFit == .fill ? safeArea.bottom : 0
) )
@@ -877,7 +881,13 @@ struct PlayerView: View {
// damit, statt hinterher beschnitten zu werden. // damit, statt hinterher beschnitten zu werden.
fläche fläche
.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit) .aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit)
.padding(safeArea) // **Gleichgemacht, nicht roh.** Der Sicherheitsrand ist
// unsymmetrisch — die Notch sitzt auf einer Seite. Mit den
// rohen Werten rückt man das Bild nicht ein, man
// **verschiebt** es um die halbe Differenz; gemessen waren
// das vierunddreissig Punkte nach rechts und elfeinhalb nach
// unten. Siehe `EdgeInsets.symmetrical`.
.padding(safeArea.symmetrical)
} else { } else {
fläche fläche
} }
@@ -903,6 +913,9 @@ struct PlayerView: View {
private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) { private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) {
guard model.videoFit != neu else { return } guard model.videoFit != neu else { return }
model.videoFit = neu model.videoFit = neu
// Ausdrücklich: die Ansicht baut ihr Seitenverhältnis von selbst ab,
// aber das Bild in der Engine liegt weiter, wie es lag.
model.applyVideoFit()
revealControls() revealControls()
} }
@@ -0,0 +1,28 @@
import SwiftUI
extension EdgeInsets {
/// Dieselben Ränder, aber auf beiden Seiten gleich — der jeweils grössere.
///
/// **Wofür das da ist.** Ein Sicherheitsrand ist selten symmetrisch: im
/// Querformat sitzt die Notch auf **einer** Seite, also zweiundsechzig
/// Punkte links und null rechts; im Hochformat sind es zweiundsechzig oben
/// und vierunddreissig unten. Wer damit ein Bild einrückt, rückt es nicht
/// ein — er **verschiebt** es, und zwar um die halbe Differenz.
///
/// Genau das war zu sehen: einunddreissig Punkte nach rechts im
/// Querformat, vierzehn nach unten im Hochformat. Gemessen an den
/// Bildschirmfotos: vierunddreissig und elfeinhalb.
///
/// **Für Bild, nicht für Knöpfe.** Ein Film soll mittig stehen, auch wenn
/// er dafür auf der freien Seite ein paar Punkte Balken mehr bekommt. Eine
/// Bedienleiste soll dagegen an der Kante kleben — die nimmt weiter die
/// echten, unsymmetrischen Werte.
public var symmetrical: EdgeInsets {
let senkrecht = max(top, bottom)
let waagerecht = max(leading, trailing)
return EdgeInsets(
top: senkrecht, leading: waagerecht,
bottom: senkrecht, trailing: waagerecht
)
}
}
@@ -54,6 +54,18 @@ public enum PlaybackEvent: Sendable, Equatable {
case reachedEnd 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. /// Der gemeinsame Nenner von AVPlayer und VLCKit.
/// ///
/// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus. /// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
@@ -84,6 +96,18 @@ public protocol VideoPlayerEngine: AnyObject {
func seek(to time: Duration) async func seek(to time: Duration) async
func setRate(_ rate: Double) 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 audioTracks: [MediaTrack] { get }
var subtitleTracks: [MediaTrack] { get } var subtitleTracks: [MediaTrack] { get }
func selectAudioTrack(_ track: MediaTrack?) func selectAudioTrack(_ track: MediaTrack?)
@@ -238,4 +262,8 @@ extension VideoPlayerEngine {
/// Wer nichts dazu sagt, kann es nicht. /// Wer nichts dazu sagt, kann es nicht.
public var supportsAirPlay: Bool { false } 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) {}
} }
@@ -0,0 +1,58 @@
import SwiftUI
import Testing
@testable import FlixyDesign
@Suite("Ränder gleichmachen")
struct SymmetricInsetsTests {
/// **Der Fall aus dem Querformat.** Notch links, nichts rechts — mit den
/// rohen Werten stünde das Bild einunddreissig Punkte zu weit rechts.
@Test("Die Notch-Seite bestimmt beide Seiten")
func landscapeNotch() {
let roh = EdgeInsets(top: 0, leading: 62, bottom: 21, trailing: 0)
let gleich = roh.symmetrical
#expect(gleich.leading == 62)
#expect(gleich.trailing == 62)
#expect(gleich.top == 21)
#expect(gleich.bottom == 21)
}
/// Und aus dem Hochformat: oben zweiundsechzig, unten vierunddreissig.
@Test("Oben und unten wird der grössere genommen")
func portrait() {
let gleich = EdgeInsets(top: 62, leading: 0, bottom: 34, trailing: 0).symmetrical
#expect(gleich.top == 62)
#expect(gleich.bottom == 62)
#expect(gleich.leading == 0)
#expect(gleich.trailing == 0)
}
/// Was schon gleich ist, bleibt gleich — sonst würde die Rechnung auf
/// einem iPad ohne Notch Balken erfinden, wo keine hingehören.
@Test("Symmetrisches bleibt unverändert")
func alreadySymmetric() {
let roh = EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50)
#expect(roh.symmetrical == roh)
}
@Test("Ohne Ränder bleibt es ohne")
func zero() {
let gleich = EdgeInsets().symmetrical
#expect(gleich == EdgeInsets())
}
/// **Die Zusage, auf die es ankommt.** Egal wie schief der Rand ankommt —
/// danach ist die Mitte des verbleibenden Platzes die Mitte des Schirms.
@Test("Die Mitte bleibt die Mitte")
func centreStaysCentred() {
for roh in [
EdgeInsets(top: 0, leading: 62, bottom: 21, trailing: 0),
EdgeInsets(top: 62, leading: 0, bottom: 34, trailing: 0),
EdgeInsets(top: 5, leading: 1, bottom: 99, trailing: 40),
] {
let g = roh.symmetrical
#expect(g.leading - g.trailing == 0)
#expect(g.top - g.bottom == 0)
}
}
}
@@ -17,7 +17,7 @@ import UIKit
public final class VLCPlayerEngine: NSObject, VideoPlayerEngine { public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
/// Die Fläche, in die VLC zeichnet. Die Ansicht hängt sie in ihren /// Die Fläche, in die VLC zeichnet. Die Ansicht hängt sie in ihren
/// Hierarchiebaum. /// Hierarchiebaum.
public let videoView = UIView() public let videoView: UIView = VLCSurfaceView()
private let player = VLCMediaPlayer() private let player = VLCMediaPlayer()
@@ -85,6 +85,14 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
private var continuation: AsyncStream<PlaybackEvent>.Continuation? private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var pendingStartTime: Duration = .zero private var pendingStartTime: Duration = .zero
/// Wie das Bild in `videoView` liegen soll.
///
/// **Gemerkt und nicht nur weitergegeben.** Solange VLC keine
/// Bildausgabe hat, gibt es nichts, worauf die Einstellung wirken könnte;
/// vor dem ersten Bild gesetzt, ist sie weg. Deshalb steht sie hier und
/// wird beim Anlaufen erneut gelegt.
private var fit: VideoFit = .fit
public override init() { public override init() {
super.init() super.init()
videoView.backgroundColor = .black videoView.backgroundColor = .black
@@ -135,6 +143,20 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
player.rate = Float(rate) player.rate = Float(rate)
} }
public func setVideoFit(_ fit: VideoFit) {
self.fit = fit
applyFit()
}
/// Legt die gemerkte Einstellung auf den Player.
///
/// `smaller` heisst bei libvlc ausdrücklich „fit inside", `larger`
/// „fit outside" — also einpassen mit Balken beziehungsweise füllen mit
/// Beschnitt. Die Namen sagen es nicht von selbst; die Kopfdatei tut es.
private func applyFit() {
player.videoFitMode = fit == .fill ? .larger : .smaller
}
public func selectAudioTrack(_ track: MediaTrack?) { public func selectAudioTrack(_ track: MediaTrack?) {
select(track, from: player.audioTracks) select(track, from: player.audioTracks)
} }
@@ -202,6 +224,9 @@ extension VLCPlayerEngine: VLCMediaPlayerDelegate {
state = .playing state = .playing
applyPendingStartTime() applyPendingStartTime()
refreshTracks() refreshTracks()
// Siehe `fit`: vor dem ersten Bild wäre sie ins Leere
// gegangen. Hier gibt es eine Ausgabe.
applyFit()
case .paused: case .paused:
state = .paused state = .paused
case .stopped, .stopping: case .stopped, .stopping:
@@ -318,3 +343,33 @@ enum AudioSessionBridge {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
} }
} }
/// Die Fläche, die VLC als Zeichenfläche bekommt.
///
/// **Eine eigene Klasse, weil VLC seine Anzeigefläche nur ein einziges Mal
/// misst.** `VLCDrawable` verlangt vom Aufnehmenden zwei Dinge: `addSubview:`
/// und `bounds`. VLCKit fragt die Grösse beim Anlegen ab, legt seine
/// `VLCVideoUIView` genau so gross an und überlässt sie 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 beim Anlegen ist die Grösse regelmässig falsch — die Fläche entsteht
/// mitten in der Drehung ins Querformat, während SwiftUI sie gerade einblendet.
/// Gemessen: eine Anzeigefläche von 1200×480 Punkt in einer Zeichenfläche von
/// 711×400. Auf dem Bildschirm sah man davon das Stück, das hineinpasste —
/// unten rechts angeschlagen, in falschem Seitenverhältnis. Genau das war
/// „das Bild sitzt nicht mittig" bei allem, was über VLC läuft.
///
/// Hier bekommt die Anzeigefläche bei jedem Layout wieder genau die eigene
/// Grösse. Das ist keine Notlösung, sondern der Teil des Vertrags, den nur der
/// Aufnehmende erfüllen kann: VLC kennt den Grund für eine Grössenänderung
/// nicht, die Ansicht kennt ihn.
final class VLCSurfaceView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
for view in subviews where view.frame != bounds {
view.frame = bounds
}
}
}
+5
View File
@@ -30,5 +30,10 @@ abgeräumt wird.
Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
wie bei einem Server, der eine Datei noch nicht untersucht hat. wie bei einem Server, der eine Datei noch nicht untersucht hat.
Mit `--vlc` melden sie DTS-Ton. AVFoundation kann das nicht, also läuft die
Wiedergabe über VLCKit — der einzige Weg, diesen Zweig mit der Beispieldatei
überhaupt zu erreichen. Die Datei bleibt dieselbe; VLC spielt sie ebenso, und
geprüft wird der Weg, nicht der Ton.
Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über
die Leitung. die Leitung.
+7 -1
View File
@@ -13,6 +13,12 @@ REPORTS = []
# Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App # Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
# auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen. # auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen.
UNANALYSED = "--unanalysed" in os.sys.argv UNANALYSED = "--unanalysed" in os.sys.argv
# Mit `--vlc` melden die Quellen DTS-Ton. AVFoundation kann das nicht, also
# faellt die App auf VLCKit zurueck — und dieser Weg ist sonst mit der
# Beispieldatei gar nicht erreichbar. Die Datei selbst bleibt dieselbe; VLC
# spielt sie ebenso, und geprueft wird der Weg, nicht der Ton.
AS_VLC = "--vlc" in os.sys.argv
AUDIO_CODEC = "dts" if AS_VLC else "aac"
TITLES = [ TITLES = [
("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."), ("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."),
@@ -49,7 +55,7 @@ def item(key, name, kind, overview, *, resume=None, series=None):
# Oberfläche zählt, was der Server meldet. # Oberfläche zählt, was der Server meldet.
{"Index": 0, "Type": "Video", "Codec": "h264", {"Index": 0, "Type": "Video", "Codec": "h264",
"Width": 1920, "Height": 1080}, "Width": 1920, "Height": 1080},
{"Index": 1, "Type": "Audio", "Codec": "aac", "Channels": 2, {"Index": 1, "Type": "Audio", "Codec": AUDIO_CODEC, "Channels": 2,
"Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True}, "Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True},
], ],
}], }],