import AVKit import SwiftUI import FlixyPlayback #if canImport(UIKit) import UIKit #else import AppKit #endif // Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der // Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird. #if canImport(FlixyVLC) && !os(tvOS) import FlixyVLC #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 des laufendie Engine. /// /// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden /// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise /// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche /// `UIView`. 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. /// VLC macht es seit jeher richtig und reicht seine `videoView` durch; /// hier ist es jetzt derselbe Weg. 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 canImport(FlixyVLC) && !os(tvOS) if let vlcEngine = engine as? VLCPlayerEngine { SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset) } else if engine is AVPlayerEngine { SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset) } else { Color.black } #else if engine is AVPlayerEngine { SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset) } else { Color.black } #endif } } /// 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