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>
218 lines
7.8 KiB
Swift
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
|