Files
Flaro/Halo/Views/Player/VideoSurface.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

218 lines
7.8 KiB
Swift

import AVKit
import SwiftUI
import HaloPlayback
#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