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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,217 @@
|
||||
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
|
||||
Reference in New Issue
Block a user