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,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 }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user