Er hat weiterhin angehalten statt ein- und auszublenden, und der Grund steckte in der Rechnung: der schwarze Rand wurde aus der Fenstergrösse ausgerechnet, das Bild liegt aber in einer Fläche mit `ignoresSafeArea()`. Auf dem Mac ist der Einzug der Fensterleiste genau der Unterschied zwischen beiden — die Rechnung lag um diesen Betrag daneben. Jetzt wird die Bildfläche gemessen statt angenommen, und beide Zahlen stehen in einem gemeinsamen, benannten Koordinatenraum. Vor allem aber wird zuerst `AVPlayerLayer.videoRect` gefragt: der Player weiss selbst, wo Bild steht. Die eigene Rechnung bleibt für den Moment vor dem ersten Bild und für VLC, das keine solche Ebene hat. Dazu zwei Worte: „Nachschub" heisst jetzt „Nachladen", und „Blatt" heisst wieder „Sheet" — das ist ein Fachbegriff und bleibt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.9 KiB
Swift
188 lines
6.9 KiB
Swift
import AVKit
|
|
import SwiftUI
|
|
import FlixyPlayback
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
#if canImport(FlixyVLC)
|
|
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
|
|
|
|
var body: some View {
|
|
#if canImport(FlixyVLC)
|
|
if let vlcEngine = engine as? VLCPlayerEngine {
|
|
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive)
|
|
} else if engine is AVPlayerEngine {
|
|
SurfaceContainer(view: surface, isActive: isActive)
|
|
} else {
|
|
Color.black
|
|
}
|
|
#else
|
|
if engine is AVPlayerEngine {
|
|
SurfaceContainer(view: surface, isActive: isActive)
|
|
} 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
|
|
|
|
func makeUIView(context: Context) -> PlatformView {
|
|
let host = UIView()
|
|
host.backgroundColor = .black
|
|
if isActive { adopt(view, into: host) }
|
|
return host
|
|
}
|
|
|
|
func updateUIView(_ host: PlatformView, context: Context) {
|
|
// 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 }
|
|
adopt(view, into: host)
|
|
}
|
|
}
|
|
#else
|
|
private struct SurfaceContainer: NSViewRepresentable {
|
|
let view: PlatformView
|
|
let isActive: Bool
|
|
|
|
func makeNSView(context: Context) -> PlatformView {
|
|
let host = NSView()
|
|
host.wantsLayer = true
|
|
host.layer?.backgroundColor = NSColor.black.cgColor
|
|
if isActive { adopt(view, into: host) }
|
|
return host
|
|
}
|
|
|
|
func updateNSView(_ host: PlatformView, context: Context) {
|
|
guard isActive, view.superview !== host else { return }
|
|
adopt(view, into: host)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// Hängt die Fläche in ihre Hülle.
|
|
///
|
|
/// Sie wird **nicht** unmittelbar an SwiftUI zurückgegeben, sondern in eine
|
|
/// eigene Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es
|
|
/// Kachel und Vollbild kurz gleichzeitig gibt: eine Ansicht hat nur einen
|
|
/// Vorgesetzten, und wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie
|
|
/// unmittelbar heraus, nahm das Abräumen der einen der anderen das Bild weg.
|
|
private func adopt(_ view: PlatformView, into host: PlatformView) {
|
|
#if canImport(UIKit)
|
|
view.translatesAutoresizingMaskIntoConstraints = true
|
|
view.frame = host.bounds
|
|
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
#else
|
|
view.translatesAutoresizingMaskIntoConstraints = true
|
|
view.frame = host.bounds
|
|
view.autoresizingMask = [.width, .height]
|
|
#endif
|
|
host.addSubview(view)
|
|
}
|