Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an einer Stelle, `PlaybackDecider.fallback`. Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket. **Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt, sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind` fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy` kennt kein `allowsFallbackEngine`. **Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern, ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer nicht an. Statt der zweiten Engine rechnet der Server jetzt neu (`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer schwarzen Wand mit einer Fehlernummer. **Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird, kommt schon in einer Hülle, die der Systemplayer öffnet. Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten: Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten auf dem Apple TV, wo es VLC ohnehin nie gab. Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist geblieben, sein Ziel nicht. iOS und tvOS bauen, 494 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
7.8 KiB
Swift
218 lines
7.8 KiB
Swift
import AVKit
|
|
import SwiftUI
|
|
import FlixyPlayback
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Die Ansichtsklasse der jeweiligen Plattform.
|
|
///
|
|
/// UIKit und AppKit heißen verschieden und meinen dasselbe. Ein Name dafür
|
|
/// spart in dieser Datei ein Dutzend `#if`.
|
|
#if canImport(UIKit)
|
|
typealias PlatformView = UIView
|
|
#else
|
|
typealias PlatformView = NSView
|
|
#endif
|
|
|
|
/// Die Bildfläche der laufenden Engine.
|
|
struct VideoSurface: View {
|
|
let engine: (any VideoPlayerEngine)?
|
|
/// Die Bildebene des Systemplayers, gehalten vom Modell.
|
|
///
|
|
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
|
|
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
|
|
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
|
|
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
|
|
let surface: PlayerLayerView
|
|
|
|
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
|
|
///
|
|
/// **Der Kern des Schwarzbilds.** Kachel und Vollbild bestehen beim
|
|
/// Wechsel kurz gleichzeitig, und beide holen sich die Fläche in ihre
|
|
/// Hülle. Eine Ansicht hat aber nur einen Vorgesetzten — wer zuletzt
|
|
/// aktualisiert, gewinnt, und das war nicht verlässlich die richtige.
|
|
/// Blieb die Fläche bei der Kachel, die gleich darauf verschwand, lief
|
|
/// im Vollbild der Ton weiter und das Bild blieb schwarz.
|
|
///
|
|
/// Die Hülle allein genügte dagegen nicht: sie verhindert, dass das
|
|
/// Abräumen der einen der anderen etwas wegnimmt, entscheidet aber
|
|
/// nicht, wer sie bekommen **soll**. Das steht hier.
|
|
var isActive = true
|
|
|
|
/// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden.
|
|
///
|
|
/// Hier steht nur die Zahl; wann sie ungleich null ist, entscheidet
|
|
/// `PlayerView.surface(for:)`.
|
|
var bottomInset: CGFloat = 0
|
|
|
|
var body: some View {
|
|
if engine is AVPlayerEngine {
|
|
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
|
} else {
|
|
Color.black
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
|
|
///
|
|
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
|
|
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
|
|
/// UIKit.
|
|
#if canImport(UIKit)
|
|
final class PlayerLayerView: UIView {
|
|
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
|
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
|
|
|
/// Die Fläche, auf der wirklich Bild steht — ohne die schwarzen Ränder.
|
|
///
|
|
/// Vom Player ausgerechnet, aus dem, was gerade ankommt. Vor dem ersten
|
|
/// Bild ist sie leer; wer sie benutzt, muss das prüfen.
|
|
var videoRect: CGRect { playerLayer.videoRect }
|
|
}
|
|
#else
|
|
/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene
|
|
/// zugewiesen, statt ihre Art vorzugeben.
|
|
final class PlayerLayerView: NSView {
|
|
let playerLayer = AVPlayerLayer()
|
|
|
|
/// Siehe die UIKit-Fassung oben.
|
|
var videoRect: CGRect { playerLayer.videoRect }
|
|
|
|
override init(frame: NSRect) {
|
|
super.init(frame: frame)
|
|
wantsLayer = true
|
|
layer = playerLayer
|
|
// Ohne das bleibt die Ebene beim Vergrößern des Fensters stehen, wo
|
|
// sie war, und das Bild sitzt in der Ecke.
|
|
layer?.needsDisplayOnBoundsChange = true
|
|
autoresizingMask = [.width, .height]
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
|
|
}
|
|
#endif
|
|
|
|
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
|
|
///
|
|
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
|
|
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
|
|
///
|
|
/// Sie wird dabei **nicht** unmittelbar zurückgegeben, sondern in eine eigene
|
|
/// Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es beide
|
|
/// Ansichten kurz gleichzeitig gibt — beim Wechsel zwischen Kachel und
|
|
/// Vollbild: eine `UIView` hat nur einen Vorgesetzten, und wer sie zuletzt
|
|
/// bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, nahm das Abräumen der
|
|
/// einen Ansicht der anderen die Bildfläche weg, und übrig blieb eine schwarze
|
|
/// Kachel — in einer fast schwarzen Oberfläche also scheinbar gar keine.
|
|
///
|
|
/// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt
|
|
/// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde.
|
|
#if canImport(UIKit)
|
|
private struct SurfaceContainer: UIViewRepresentable {
|
|
let view: PlatformView
|
|
let isActive: Bool
|
|
let bottomInset: CGFloat
|
|
|
|
func makeUIView(context: Context) -> SurfaceHostView {
|
|
let host = SurfaceHostView()
|
|
host.backgroundColor = .black
|
|
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
|
if isActive { host.adopt(view) }
|
|
return host
|
|
}
|
|
|
|
func updateUIView(_ host: SurfaceHostView, context: Context) {
|
|
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
|
// Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen
|
|
// sich beide Ansichten dieselbe Fläche, und die Reihenfolge
|
|
// entscheidet — mal richtig, mal schwarz.
|
|
guard isActive, view.superview !== host else { return }
|
|
host.adopt(view)
|
|
}
|
|
}
|
|
#else
|
|
private struct SurfaceContainer: NSViewRepresentable {
|
|
let view: PlatformView
|
|
let isActive: Bool
|
|
let bottomInset: CGFloat
|
|
|
|
func makeNSView(context: Context) -> SurfaceHostView {
|
|
let host = SurfaceHostView()
|
|
host.wantsLayer = true
|
|
host.layer?.backgroundColor = NSColor.black.cgColor
|
|
host.bottomInset = bottomInset
|
|
if isActive { host.adopt(view) }
|
|
return host
|
|
}
|
|
|
|
func updateNSView(_ host: SurfaceHostView, context: Context) {
|
|
host.bottomInset = bottomInset
|
|
guard isActive, view.superview !== host else { return }
|
|
host.adopt(view)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// Die Hülle, die der Bildfläche ihren Platz zuweist.
|
|
///
|
|
/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die
|
|
/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält
|
|
/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse
|
|
/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand
|
|
/// verlangt echtes Layout — und das sind genau diese vier Zeilen.
|
|
#if canImport(UIKit)
|
|
final class SurfaceHostView: UIView {
|
|
/// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`.
|
|
var contentInset: UIEdgeInsets = .zero {
|
|
didSet {
|
|
guard contentInset != oldValue else { return }
|
|
setNeedsLayout()
|
|
}
|
|
}
|
|
|
|
/// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`.
|
|
func adopt(_ view: UIView) {
|
|
view.translatesAutoresizingMaskIntoConstraints = true
|
|
view.autoresizingMask = []
|
|
addSubview(view)
|
|
setNeedsLayout()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
subviews.first?.frame = bounds.inset(by: contentInset)
|
|
}
|
|
}
|
|
#else
|
|
final class SurfaceHostView: NSView {
|
|
var bottomInset: CGFloat = 0 {
|
|
didSet {
|
|
guard bottomInset != oldValue else { return }
|
|
needsLayout = true
|
|
}
|
|
}
|
|
|
|
func adopt(_ view: NSView) {
|
|
view.translatesAutoresizingMaskIntoConstraints = true
|
|
view.autoresizingMask = []
|
|
addSubview(view)
|
|
needsLayout = true
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
// Auf dem Mac liegt der Ursprung unten links: einzurücken heisst
|
|
// hier, den Ursprung anzuheben und die Höhe zu kürzen.
|
|
var rahmen = bounds
|
|
rahmen.origin.y += bottomInset
|
|
rahmen.size.height -= bottomInset
|
|
subviews.first?.frame = rahmen
|
|
}
|
|
}
|
|
#endif
|