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>
217 lines
9.1 KiB
Swift
217 lines
9.1 KiB
Swift
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 }
|
|
}
|
|
}
|