import AVKit import UIKit import FlaroPlayback 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? 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 } } }