Files
Flaro/Flixy/Views/Player/VideoSurface.swift
T
ScarriffleandClaude Opus 5 279d8e36ae Zwei Fehler um dieselbe Bildfläche
**Die Wiedergabe sprang von selbst wieder an.** Auf dem Mac pausiert ein
Klick ins Bild — und der Klick, mit dem man ein Fenster nach vorn holt,
kommt ebenfalls dort an. Man aktiviert das Fenster und drückt dabei auf
Play. Klicks innerhalb einer Viertelsekunde nach dem Aktivieren zählen
deshalb nicht: länger braucht niemand bis zum ersten gemeinten Klick,
kürzer erwischt den Fall nicht.

**Schwarzbild mit Ton nach dem Vergrössern.** Kachel und Vollbild
bestehen beim Wechsel kurz gleichzeitig, und beide holten sich dieselbe
Bildflä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 nicht: sie verhindert, dass das Abräumen der
einen Ansicht der anderen etwas wegnimmt, entscheidet aber nicht, wer
die Fläche bekommen **soll**. Das sagt jetzt `isActive` — abgeleitet aus
`playback.presentation`, also aus derselben Quelle, die auch bestimmt,
welche Ansicht überhaupt zu sehen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:44:20 +02:00

179 lines
6.6 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 }
}
#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()
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)
}