Ein Untertitelmenü statt zweier — und Staffel statt S
Zwei Sachen, die am laufenden Player auffielen. **Zwei Sprechblasen nebeneinander.** Eine war Apples eingebautes Untertitelmenü, eine unseres — und welche was tut, sah man erst nach dem Drücken. Schlimmer als die Doppelung war, was dahinterlag: Apples Menü verstellt die **Systemeinstellung** für Untertitel, während unsere Engine `AVTextStyleRule`s aus `SubtitleAppearance` setzt. Zwei Stilregler, von denen nur einer wirkt. Apples Knopf ist jetzt abgeschaltet — `allowedSubtitleOptionLanguages` auf eine leere Liste, der einzige dokumentierte Hebel dafür; die Auswahl von Hand bleibt unberührt. Unser Menü trägt die Spuren oben und darunter Schriftgröße und Darstellung als Untermenüs. Tiefer geht es nicht: die Transportleiste zeigt genau eine Ebene. **Und das Tonmenü verschwindet, wenn Apple es besser 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 wären dasselbe zweimal. Nur beim Umrechnen liegt im Stream genau eine, und dann führt allein unseres weiter. **„S7 E2" heisst jetzt „Staffel 7 · Episode 2"**, wo Platz ist: in der Titelzeile des Players und im Aufmacher am Fernseher. Auf einer Kachel bleibt die kurze Form — dort ist eine Zeile breit, und ausgeschrieben verdrängte sie den Titel. Mitgewandert in die geteilte Schicht sind die Namen der Stilstufen: sie standen bei der Einstellungsansicht und wurden damit nur vom Telefon übersetzt. Zwei Listen gleich zu halten ist genau die Art Fehler, die erst auffällt, wenn jemand „Gelb auf Schwarz" wählt und „Weiss auf Schwarz" bekommt. Der Stil wirkt sofort und ohne Neuaufbau, und die Haken im Menü wandern mit: dafür steht er in der Momentaufnahme, obwohl der Player selbst ihn nicht braucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
12bfc9e530
commit
e2714e058e
@@ -50,7 +50,9 @@ struct TVHeroSection: View {
|
||||
private var metadaten: String? {
|
||||
var teile: [String] = []
|
||||
if item.kind == .episode, let staffel = item.parentIndex, let folge = item.index {
|
||||
teile.append(String(localized: "episode.short \(staffel) \(folge)"))
|
||||
// Ausgeschrieben: der Aufmacher am Fernseher ist 1760 Punkte
|
||||
// breit, und gelesen wird er aus drei Metern.
|
||||
teile.append(String(localized: "episode.long \(staffel) \(folge)"))
|
||||
}
|
||||
if let jahr = item.year { teile.append(String(jahr)) }
|
||||
if let dauer = item.duration {
|
||||
|
||||
@@ -21,6 +21,7 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
|
||||
let controller = AVPlayerViewController()
|
||||
|
||||
private let onDismiss: () -> Void
|
||||
private let appearance: SubtitleAppearance
|
||||
private var letzteAufnahme: TVPlayerSnapshot?
|
||||
/// Wacht darüber, wann es etwas zu beschriften gibt.
|
||||
///
|
||||
@@ -35,7 +36,8 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
|
||||
private lazy var abdeckung = TVRebuildOverlay()
|
||||
private var abdeckungsAufgabe: Task<Void, Never>?
|
||||
|
||||
init(onDismiss: @escaping () -> Void) {
|
||||
init(appearance: SubtitleAppearance, onDismiss: @escaping () -> Void) {
|
||||
self.appearance = appearance
|
||||
self.onDismiss = onDismiss
|
||||
super.init()
|
||||
controller.delegate = self
|
||||
@@ -44,6 +46,20 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
|
||||
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
|
||||
@@ -79,6 +95,7 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
|
||||
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 }
|
||||
|
||||
@@ -21,6 +21,8 @@ struct TVPlayerHost: UIViewControllerRepresentable {
|
||||
/// 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 {
|
||||
@@ -32,7 +34,7 @@ struct TVPlayerHost: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> TVPlayerCoordinator {
|
||||
TVPlayerCoordinator(onDismiss: onDismiss)
|
||||
TVPlayerCoordinator(appearance: appearance, onDismiss: onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +51,19 @@ struct TVPlayerSnapshot: Equatable {
|
||||
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, offLabel: String) {
|
||||
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(
|
||||
|
||||
@@ -35,7 +35,12 @@ struct TVPlayerScreen: View {
|
||||
// ein `updateUIViewController`.
|
||||
TVPlayerHost(
|
||||
model: model,
|
||||
snapshot: TVPlayerSnapshot(model, offLabel: String(localized: "player.subtitlesOff")),
|
||||
snapshot: TVPlayerSnapshot(
|
||||
model,
|
||||
appearance: subtitleAppearance,
|
||||
offLabel: String(localized: "player.subtitlesOff")
|
||||
),
|
||||
appearance: subtitleAppearance,
|
||||
onDismiss: { playback.close() }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
@@ -43,6 +48,19 @@ struct TVPlayerScreen: View {
|
||||
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)
|
||||
}
|
||||
.task {
|
||||
guard model == nil else { return }
|
||||
let frisch = PlayerModel(
|
||||
|
||||
@@ -8,65 +8,117 @@ import FlixyPlayback
|
||||
/// 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] = []
|
||||
if aufnahme.audio.count > 1 {
|
||||
menüs.append(menü(
|
||||
titel: String(localized: "player.audioTracks"),
|
||||
symbol: "waveform",
|
||||
einträge: aufnahme.audio,
|
||||
gesperrt: aufnahme.isRebuilding,
|
||||
aktion: onAudio
|
||||
))
|
||||
}
|
||||
if aufnahme.subtitles.count > 1 {
|
||||
menüs.append(menü(
|
||||
titel: String(localized: "player.subtitleTracks"),
|
||||
symbol: "captions.bubble",
|
||||
einträge: aufnahme.subtitles,
|
||||
gesperrt: aufnahme.isRebuilding,
|
||||
aktion: onSubtitle
|
||||
|
||||
// **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
|
||||
}
|
||||
|
||||
private static func menü(
|
||||
titel: String,
|
||||
symbol: String,
|
||||
einträge: [TrackMenu.Entry],
|
||||
// MARK: - Die Spuren
|
||||
|
||||
private static func einträge(
|
||||
_ liste: [TrackMenu.Entry],
|
||||
gesperrt: Bool,
|
||||
aktion: @escaping (Int?) -> Void
|
||||
) -> UIMenu {
|
||||
) -> [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: titel,
|
||||
image: UIImage(systemName: symbol),
|
||||
title: String(localized: "subtitleStyle.sizeHeader"),
|
||||
image: UIImage(systemName: "textformat.size"),
|
||||
options: [.singleSelection],
|
||||
children: einträge.map { eintrag in
|
||||
children: SubtitleStyle.Size.allCases.map { größe 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
|
||||
title: größe.label,
|
||||
state: appearance.size == größe ? .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)
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user