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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,216 @@
import AVKit
import UIKit
import HaloPlayback
import MediaKit
/// Führt Apples Player — und hält ihn über einen Neuaufbau hinweg am Leben.
///
/// **Das Problem, um das es hier geht.** Unsere Ton- und Untertitelwahl geht
/// als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen
/// **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die
/// `AVMediaSelectionGroup`s des **laufenden** Streams — beim Transcode genau
/// eine. Er ist also die richtige Oberfläche für die falsche Liste.
///
/// Deshalb bekommt die Transportleiste unsere eigenen Menüs über
/// `transportBarCustomMenuItems`. Nicht `customInfoViewControllers`: die Wahl
/// der Tonspur ist ein Bedienelement der Wiedergabe, kein Begleitmaterial —
/// Apples eigene Beispiele dafür sind Bonusinhalte und Verwandtes, und dort
/// erwartet niemand „Sprache".
@MainActor
final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDelegate {
let controller = AVPlayerViewController()
private let onDismiss: () -> Void
private let appearance: SubtitleAppearance
private var letzteAufnahme: TVPlayerSnapshot?
/// Wacht darüber, wann es etwas zu beschriften gibt.
///
/// **Nicht beim Austausch des Players.** In dem Moment ist `currentItem`
/// noch `nil` — das Item entsteht erst, wenn das Asset geladen ist, und
/// bis dahin zeigt Apples Titelzeile, was es selbst aus der Datei liest:
/// „Unbekannt". Also wird gewartet, bis das Item wirklich da ist.
private var itemBeobachter: NSKeyValueObservation?
/// Was drinstehen soll, sobald es ein Item gibt.
private var beschriftung: (titel: String, untertitel: String?)?
/// Die Abdeckung für das schwarze Bild während eines Neuaufbaus.
private lazy var abdeckung = TVRebuildOverlay()
private var abdeckungsAufgabe: Task<Void, Never>?
init(appearance: SubtitleAppearance, onDismiss: @escaping () -> Void) {
self.appearance = appearance
self.onDismiss = onDismiss
super.init()
controller.delegate = self
controller.videoGravity = .resizeAspect
// Nie auf dem Fernseher — ein Apple TV hat kein zweites Fenster.
controller.allowsPictureInPicturePlayback = false
controller.transportBarIncludesTitleView = true
// **Apples eigenen Untertitelknopf abschalten.**
//
// Sonst stehen zwei Sprechblasen nebeneinander, und welche was tut,
// sieht man erst nach dem Drücken. Schlimmer noch: Apples Menü
// verstellt die **Systemeinstellung** für Untertitel, während unsere
// Engine `AVTextStyleRule`s aus `SubtitleAppearance` setzt — zwei
// Stilregler, von denen nur einer wirkt.
//
// `nil` heisst „alle Sprachen anbieten", eine leere Liste also keine.
// Das ist der einzige dokumentierte Hebel dafür; die Auswahl von Hand
// (`AVPlayerEngine`) bleibt davon unberührt, hier geht es nur um das,
// was die Leiste anbietet.
controller.allowedSubtitleOptionLanguages = []
// **Hinter den Bedienelementen und ohne Fokus.** `contentOverlayView`
// liegt über dem Bild, aber unter Apples Leiste, und nimmt keine
// Eingaben an. `customOverlayViewController` täte beides nicht: der
// ersetzt die ganze Überlagerung und zieht den Fokus an sich — damit
// wäre die Fernbedienung während des Umschaltens tot.
if let ebene = controller.contentOverlayView {
abdeckung.translatesAutoresizingMaskIntoConstraints = false
ebene.addSubview(abdeckung)
NSLayoutConstraint.activate([
abdeckung.topAnchor.constraint(equalTo: ebene.topAnchor),
abdeckung.bottomAnchor.constraint(equalTo: ebene.bottomAnchor),
abdeckung.leadingAnchor.constraint(equalTo: ebene.leadingAnchor),
abdeckung.trailingAnchor.constraint(equalTo: ebene.trailingAnchor),
])
}
}
// MARK: - Nachführen
func apply(_ aufnahme: TVPlayerSnapshot, model: PlayerModel) {
defer { letzteAufnahme = aufnahme }
// **Den Player nur austauschen, wenn es ein anderer ist.** `restart()`
// baut eine neue Engine mit einem neuen `AVPlayer`; genau dann — und
// nur dann — wird das Bild schwarz.
if aufnahme.playerIdentity != letzteAufnahme?.playerIdentity {
controller.player = model.systemPlayer
beobachteItem()
}
beschriftung = (aufnahme.title, aufnahme.subtitle)
beschrifte()
zeigeAbdeckung(aufnahme.isRebuilding)
controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items(
for: aufnahme,
appearance: appearance,
onAudio: { [weak model] index in
guard let model, let stream = model.audioStreams.first(where: { $0.index == index })
else { return }
Task { await model.selectAudioStream(stream) }
},
onSubtitle: { [weak model] index in
guard let model else { return }
let stream = index.flatMap { i in
model.subtitleStreams.first { $0.index == i }
}
Task { await model.selectSubtitleStream(stream) }
}
)
controller.contextualActions = TVTrackMenuBuilder.contextualActions(
for: aufnahme,
onSkip: { [weak model] in model?.skipActiveMarker() }
)
}
private func beobachteItem() {
itemBeobachter = controller.player?.observe(\.currentItem, options: [.initial, .new]) {
[weak self] _, _ in
MainActor.assumeIsolated { self?.beschrifte() }
}
}
/// Titel und Untertitel in Apples Titelzeile.
///
/// Über `externalMetadata` am `AVPlayerItem`, weil die Transportleiste
/// genau dort nachsieht. Auf iOS zeichnet unsere eigene Kopfzeile das;
/// hier gäbe es sonst gar keine Auskunft darüber, was läuft — ausser dem,
/// was AVKit selbst aus der Datei liest, und das ist bei einem
/// Transcode-Stream „Unbekannt".
private func beschrifte() {
guard let item = controller.player?.currentItem,
let beschriftung
else { return }
var einträge: [AVMetadataItem] = [eintrag(.commonIdentifierTitle, beschriftung.titel)]
if let untertitel = beschriftung.untertitel {
einträge.append(eintrag(.iTunesMetadataTrackSubTitle, untertitel))
}
item.externalMetadata = einträge
}
private func eintrag(_ kennung: AVMetadataIdentifier, _ wert: String) -> AVMetadataItem {
let eintrag = AVMutableMetadataItem()
eintrag.identifier = kennung
eintrag.value = wert as NSString
eintrag.extendedLanguageTag = "und"
return eintrag
}
/// Blendet die Abdeckung ein — aber erst nach einer Viertelsekunde.
///
/// Der häufige Fall (unverändert gespielt, unter fünfzig Millisekunden)
/// soll nicht aufblitzen. Wer nur umschaltet, sieht dann gar nichts.
private func zeigeAbdeckung(_ sichtbar: Bool) {
abdeckungsAufgabe?.cancel()
guard sichtbar else {
abdeckung.setVisible(false)
return
}
abdeckungsAufgabe = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
self?.abdeckung.setVisible(true)
}
}
// MARK: - Der Menü-Knopf der Fernbedienung
/// Fängt den Menü-Knopf ab, statt in SwiftUI `onExitCommand` zu raten.
func playerViewControllerShouldDismiss(_ controller: AVPlayerViewController) -> Bool {
onDismiss()
return true
}
}
/// Die Abdeckung, die das schwarze Bild während eines Neuaufbaus zudeckt.
///
/// Ohne sie sieht man Sekunden lang nichts und hält es für einen Absturz —
/// dabei ist es die angekündigte Folge einer Spurwahl.
@MainActor
final class TVRebuildOverlay: UIView {
private let kreisel = UIActivityIndicatorView(style: .large)
private let zeile = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.75)
isUserInteractionEnabled = false
alpha = 0
kreisel.color = .white
kreisel.startAnimating()
zeile.text = String(localized: "player.rebuilding")
zeile.textColor = .white
zeile.font = .preferredFont(forTextStyle: .headline)
let stapel = UIStackView(arrangedSubviews: [kreisel, zeile])
stapel.axis = .vertical
stapel.alignment = .center
stapel.spacing = 24
stapel.translatesAutoresizingMaskIntoConstraints = false
addSubview(stapel)
NSLayoutConstraint.activate([
stapel.centerXAnchor.constraint(equalTo: centerXAnchor),
stapel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
func setVisible(_ sichtbar: Bool) {
UIView.animate(withDuration: 0.2) { self.alpha = sichtbar ? 1 : 0 }
}
}
+89
View File
@@ -0,0 +1,89 @@
import AVKit
import SwiftUI
import HaloPlayback
import MediaKit
/// Hängt Apples Player in den SwiftUI-Baum.
///
/// **Wem was gehört.** Der `AVPlayerViewController` gehört dem Coordinator,
/// nicht der SwiftUI-Struktur — die wird bei jedem Neuzeichnen neu gebaut —
/// und nicht dem `PlayerModel`, das frei von UIKit bleiben soll. Das Modell
/// gibt nur seinen `AVPlayer` heraus, und das ist AVFoundation.
@MainActor
struct TVPlayerHost: UIViewControllerRepresentable {
let model: PlayerModel
/// Eine Momentaufnahme dessen, was den Controller betrifft.
///
/// **Als Wert, nicht als Verweis.** SwiftUI ruft `updateUIViewController`
/// nur, wenn sich am Repräsentanten etwas geändert hat. Stünde hier allein
/// das Modell, änderte sich nie etwas: es ist eine Klasse, und ihre
/// Kennung bleibt über jeden Spurwechsel hinweg dieselbe. Der Wert wird im
/// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die
/// Beobachtungsbeziehung zu `@Observable`.
let snapshot: TVPlayerSnapshot
/// Der Untertitelstil — er steht im selben Menü wie die Spurwahl.
let appearance: SubtitleAppearance
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> AVPlayerViewController {
context.coordinator.controller
}
func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {
context.coordinator.apply(snapshot, model: model)
}
func makeCoordinator() -> TVPlayerCoordinator {
TVPlayerCoordinator(appearance: appearance, onDismiss: onDismiss)
}
}
/// Was der Controller von der Wiedergabe wissen muss — und mehr nicht.
struct TVPlayerSnapshot: Equatable {
/// Wechselt bei jedem Neuaufbau. Daran hängt der Austausch des Players.
let playerIdentity: ObjectIdentifier?
let audio: [TrackMenu.Entry]
let subtitles: [TrackMenu.Entry]
/// Läuft gerade ein Neuaufbau? Dann ist das Bild schwarz und das Menü
/// gesperrt.
let isRebuilding: Bool
let title: String
let subtitle: String?
/// Der Zeitbereich einer Marke — für „Intro überspringen".
let markerLabel: String?
/// Der eingestellte Untertitelstil.
///
/// **Gehört in die Momentaufnahme, obwohl der Player ihn nicht braucht.**
/// Er steht im selben Menü wie die Spurwahl, und ohne ihn hier merkte
/// SwiftUI nicht, dass sich etwas geändert hat — die Haken blieben beim
/// alten Wert stehen, während die Untertitel längst anders aussähen.
let subtitleStyle: SubtitleStyle
/// `@MainActor`, weil das Modell es ist: gelesen wird hier zwölfmal aus
/// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur.
@MainActor
init(_ model: PlayerModel, appearance: SubtitleAppearance, offLabel: String) {
subtitleStyle = appearance.style
playerIdentity = model.systemPlayer.map(ObjectIdentifier.init)
let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false }
audio = TrackMenu.audio(
from: model.audioStreams,
selected: model.selectedAudioStream,
isDirectPlay: direkt,
playerTrackCount: model.audioTracks.count
)
subtitles = TrackMenu.subtitles(
from: model.subtitleStreams,
selected: model.selectedSubtitleStream,
isDirectPlay: direkt,
playerTrackLanguages: model.subtitleTracks.map(\.languageCode),
offLabel: offLabel
)
isRebuilding = model.isChangingQuality
title = model.title
subtitle = model.subtitle
markerLabel = model.activeMarker.map {
String(localized: $0.isIntro ? "player.skipIntro" : "player.skipCredits")
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Der Player auf dem Fernseher.
///
/// **Das Modell lebt hier und stirbt hier.** Auf dem Telefon hält `RootView`
/// es fest, damit die Wiedergabe den Wechsel zwischen Kachel und Vollbild
/// überlebt. Ohne Mini-Player gibt es diesen Wechsel nicht, und ein
/// hochgehaltenes Modell hielte nur eine tote Engine, einen laufenden
/// Transcoder und eine offene Jellyfin-Sitzung am Leben, nachdem der Player
/// längst weg ist.
struct TVPlayerScreen: View {
let request: PlaybackRequest
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(PlaybackDiagnostics.self) private var diagnostics
@State private var model: PlayerModel?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let model {
// **Die Momentaufnahme entsteht hier, im Rumpf einer View.**
// Nur so entsteht die Beobachtungsbeziehung zu `@Observable`
// — ein Repräsentant, der bloss das Modell hält, bekäme nie
// ein `updateUIViewController`.
TVPlayerHost(
model: model,
snapshot: TVPlayerSnapshot(
model,
appearance: subtitleAppearance,
offLabel: String(localized: "player.subtitlesOff")
),
appearance: subtitleAppearance,
onDismiss: { playback.close() }
)
.ignoresSafeArea()
} else {
ProgressView()
}
}
// **Der Stil wirkt sofort, ohne Neuaufbau.**
//
// Auf `style` und nicht auf die Klasse: ein `@Observable` meldet jede
// Berührung, und dann liefe das hier auch, wenn sich nichts geändert
// hat. `SubtitleStyle` ist vergleichbar und feuert nur bei einer
// echten Änderung.
//
// Dass es hier steht und nicht im Coordinator, hat denselben Grund
// wie die Momentaufnahme: nur im Rumpf einer View entsteht die
// Beobachtungsbeziehung.
.onChange(of: subtitleAppearance.style) { _, neu in
model?.engine?.applySubtitleStyle(neu)
}
// **Der Menü-Knopf wirkt auch, solange noch nichts läuft.**
//
// `AVPlayerViewController` fängt ihn selbst ab — aber erst, wenn er
// einen Player mit einem Item hat. Solange der Stream noch
// ausgehandelt wird oder der Server erst anfängt zu rechnen, gibt es
// den nicht, und man sass fest: schwarzes Bild, Kreisel, kein Weg
// zurück. Hier liegt der Griff eine Ebene darüber, wo es ihn vom
// ersten Bild an gibt.
//
// Doppelt ist er nicht: greift Apples Player zu, kommt der Befehl
// hier gar nicht mehr an.
.onExitCommand { playback.close() }
.task {
guard model == nil else { return }
let frisch = PlayerModel(
request: request,
backend: session.backend,
liveTV: nil, // Kein Live-TV in Fassung eins.
downloads: nil, // Nie auf dem Fernseher.
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
languages: languages,
subtitles: subtitleAppearance,
isLocalConnection: session.isLocalConnection,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
)
model = frisch
await frisch.start()
}
// Beim Verschwinden wirklich Schluss: Engine abräumen, Fortschritt
// melden, die Jellyfin-Sitzung schliessen. Ohne das liefe der
// Transcoder auf dem Server weiter, obwohl niemand mehr zusieht.
.onDisappear { [model] in Task { await model?.stop() } }
}
}
@@ -0,0 +1,137 @@
import AVKit
import UIKit
import HaloPlayback
/// Baut aus unseren Spurlisten die Menüs für Apples Transportleiste.
///
/// Die reine Abbildung steht in `TrackMenu` und ist dort geprüft; hier geht es
/// nur noch um UIKit.
@MainActor
enum TVTrackMenuBuilder {
/// - Parameter appearance: Der Untertitelstil. Er steht **in** diesem
/// Menü und nicht daneben: vorher gab es zwei Knöpfe mit derselben
/// Sprechblase — einen für die Spur, einen für den Stil —, und welcher
/// was tat, sah man erst nach dem Drücken.
static func items(
for aufnahme: TVPlayerSnapshot,
appearance: SubtitleAppearance,
onAudio: @escaping (Int?) -> Void,
onSubtitle: @escaping (Int?) -> Void
) -> [UIMenuElement] {
var menüs: [UIMenuElement] = []
// **Unser Tonmenü nur, wenn Apples eigenes es nicht kann.**
//
// Wird die Datei unverändert gespielt und stimmt die Spurzahl, liegen
// alle Spuren im Stream — dann zeigt Apple sie selbst, und zwei
// Knöpfe nebeneinander wären dasselbe zweimal. Beim Umrechnen liegt
// im Stream dagegen genau eine, und nur unser Menü führt weiter.
if aufnahme.audio.count > 1, aufnahme.audio.contains(where: \.rebuilds) {
menüs.append(UIMenu(
title: String(localized: "player.audioTracks"),
image: UIImage(systemName: "waveform"),
options: [.singleSelection],
children: einträge(aufnahme.audio, gesperrt: aufnahme.isRebuilding, aktion: onAudio)
))
}
// **Untertitel immer über uns**, auch wenn Apple es könnte: hier hängt
// der Stil mit drin, und der ist unserer — `AVTextStyleRule`s aus
// `SubtitleAppearance`, nicht die Systemeinstellung, die Apples Menü
// verstellt. Apples eigener Knopf ist deshalb abgeschaltet; siehe
// `TVPlayerCoordinator`.
menüs.append(UIMenu(
title: String(localized: "player.subtitleTracks"),
image: UIImage(systemName: "captions.bubble"),
children: [
// Inline: die Spuren stehen ohne Umweg da, der Stil darunter
// als eigene Untermenüs. Tiefer geht es nicht — die
// Transportleiste zeigt genau eine Ebene.
UIMenu(options: [.displayInline, .singleSelection],
children: einträge(aufnahme.subtitles,
gesperrt: aufnahme.isRebuilding,
aktion: onSubtitle)),
größenMenü(appearance),
darstellungsMenü(appearance),
]
))
return menüs
}
// MARK: - Die Spuren
private static func einträge(
_ liste: [TrackMenu.Entry],
gesperrt: Bool,
aktion: @escaping (Int?) -> Void
) -> [UIMenuElement] {
liste.map { eintrag in
UIAction(
title: eintrag.title,
// **Vorher ansagen, nicht hinterher erklären.** Steht hier
// nichts, ist die Sekundenpause danach eine Panne; steht es
// hier, ist sie eine angekündigte Folge. Das ist der billigste
// Teil dieser ganzen Sache und der wirksamste.
subtitle: eintrag.rebuilds ? String(localized: "player.rebuilds") : nil,
// **Keine zweite Bestellung, solange die erste läuft.** Sonst
// räumt `restart()` eine Engine ab, die eine andere gerade baut.
attributes: gesperrt ? [.disabled] : [],
state: eintrag.isSelected ? .on : .off
) { auslöser in
// **Sofort quittieren.** Das Menü schliesst sich beim Drücken,
// und darin ist kein Platz für einen Kreisel. Der Haken
// wandert, bevor sich am Bild etwas tut.
auslöser.state = .on
aktion(eintrag.id)
}
}
}
// MARK: - Der Stil
private static func größenMenü(_ appearance: SubtitleAppearance) -> UIMenu {
UIMenu(
title: String(localized: "subtitleStyle.sizeHeader"),
image: UIImage(systemName: "textformat.size"),
options: [.singleSelection],
children: SubtitleStyle.Size.allCases.map { größe in
UIAction(
title: größe.label,
state: appearance.size == größe ? .on : .off
) { auslöser in
auslöser.state = .on
appearance.size = größe
}
}
)
}
private static func darstellungsMenü(_ appearance: SubtitleAppearance) -> UIMenu {
UIMenu(
title: String(localized: "subtitleStyle.paletteHeader"),
image: UIImage(systemName: "paintpalette"),
options: [.singleSelection],
children: SubtitleStyle.Palette.allCases.map { palette in
UIAction(
title: palette.label,
state: appearance.palette == palette ? .on : .off
) { auslöser in
auslöser.state = .on
appearance.palette = palette
}
}
)
}
/// „Intro überspringen" unten rechts, solange eine Marke läuft.
///
/// Die tvOS-Entsprechung zu dem Knopf, den der iOS-Player selbst zeichnet.
static func contextualActions(
for aufnahme: TVPlayerSnapshot,
onSkip: @escaping () -> Void
) -> [UIAction] {
guard let beschriftung = aufnahme.markerLabel else { return [] }
return [UIAction(title: beschriftung) { _ in onSkip() }]
}
}