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:
Scarriffle
2026-09-29 20:05:28 +02:00
co-authored by Claude Opus 5
parent 12bfc9e530
commit e2714e058e
9 changed files with 221 additions and 73 deletions
+35 -1
View File
@@ -1551,6 +1551,23 @@
} }
} }
}, },
"episode.long %lld %lld": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel %lld · Episode %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Season %lld · Episode %lld"
}
}
}
},
"episode.number %lld": { "episode.number %lld": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@@ -1569,7 +1586,7 @@
} }
}, },
"episode.short %lld %lld": { "episode.short %lld %lld": {
"comment": "Staffel- und Folgennummer", "comment": "Nur auf Kacheln, wo eine Zeile breit ist. Wo Platz ist — Player, Aufmacher am Fernseher — steht episode.long.",
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
"de": { "de": {
@@ -6959,6 +6976,23 @@
} }
} }
}, },
"subtitleStyle.sizeHeader": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schriftgröße"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Text size"
}
}
}
},
"subtitleStyle.title": { "subtitleStyle.title": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
+37
View File
@@ -0,0 +1,37 @@
import FlixyPlayback
import Foundation
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
/// Muster wie bei `PlaybackSettings.Quality`: `SubtitleStyle` liegt im
/// prüfbaren Teil und soll keinen Textkatalog kennen.
///
/// **Hier und nicht bei der Einstellungsansicht.** Dort standen sie, und
/// damit übersetzte nur das Telefon sie: am Fernseher steht derselbe Stil im
/// Untertitelmenü des Players, und 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.
///
/// Als `String` und nicht als `LocalizedStringKey`: ein `UIMenu` nimmt keinen
/// Schlüssel entgegen, und `Text` kommt mit beidem zurecht.
extension SubtitleStyle.Size {
var label: String {
switch self {
case .small: String(localized: "subtitleStyle.size.small")
case .normal: String(localized: "subtitleStyle.size.normal")
case .large: String(localized: "subtitleStyle.size.large")
case .huge: String(localized: "subtitleStyle.size.huge")
}
}
}
extension SubtitleStyle.Palette {
var label: String {
switch self {
case .whiteOnClear: String(localized: "subtitleStyle.palette.whiteOnClear")
case .whiteOnBlack: String(localized: "subtitleStyle.palette.whiteOnBlack")
case .blackOnWhite: String(localized: "subtitleStyle.palette.blackOnWhite")
case .yellowOnClear: String(localized: "subtitleStyle.palette.yellowOnClear")
case .yellowOnBlack: String(localized: "subtitleStyle.palette.yellowOnBlack")
}
}
}
+4 -1
View File
@@ -2049,7 +2049,10 @@ final class PlayerModel {
private func playerSubtitle(for item: MediaItem) -> String? { private func playerSubtitle(for item: MediaItem) -> String? {
guard item.kind == .episode else { return nil } guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index { if let season = item.parentIndex, let episode = item.index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)" // **Ausgeschrieben, nicht abgekürzt.** Diese Zeile steht in der
// Titelzeile des Players und hat die volle Breite; „S7 E2" spart
// dort nichts und liest sich wie eine Signatur.
return "\(String(localized: "episode.long \(season) \(episode)")) · \(item.title)"
} }
return item.title return item.title
} }
@@ -98,28 +98,3 @@ extension SubtitleStyle.RGBA {
Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha)
} }
} }
/// Die Namen der Stufen stehen neben der Ansicht, nicht im Modell — dasselbe
/// Muster wie bei `PlaybackSettings.Quality`.
extension SubtitleStyle.Size {
var label: LocalizedStringKey {
switch self {
case .small: "subtitleStyle.size.small"
case .normal: "subtitleStyle.size.normal"
case .large: "subtitleStyle.size.large"
case .huge: "subtitleStyle.size.huge"
}
}
}
extension SubtitleStyle.Palette {
var label: LocalizedStringKey {
switch self {
case .whiteOnClear: "subtitleStyle.palette.whiteOnClear"
case .whiteOnBlack: "subtitleStyle.palette.whiteOnBlack"
case .blackOnWhite: "subtitleStyle.palette.blackOnWhite"
case .yellowOnClear: "subtitleStyle.palette.yellowOnClear"
case .yellowOnBlack: "subtitleStyle.palette.yellowOnBlack"
}
}
}
+3 -1
View File
@@ -50,7 +50,9 @@ struct TVHeroSection: View {
private var metadaten: String? { private var metadaten: String? {
var teile: [String] = [] var teile: [String] = []
if item.kind == .episode, let staffel = item.parentIndex, let folge = item.index { 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 jahr = item.year { teile.append(String(jahr)) }
if let dauer = item.duration { if let dauer = item.duration {
+18 -1
View File
@@ -21,6 +21,7 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
let controller = AVPlayerViewController() let controller = AVPlayerViewController()
private let onDismiss: () -> Void private let onDismiss: () -> Void
private let appearance: SubtitleAppearance
private var letzteAufnahme: TVPlayerSnapshot? private var letzteAufnahme: TVPlayerSnapshot?
/// Wacht darüber, wann es etwas zu beschriften gibt. /// 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 lazy var abdeckung = TVRebuildOverlay()
private var abdeckungsAufgabe: Task<Void, Never>? private var abdeckungsAufgabe: Task<Void, Never>?
init(onDismiss: @escaping () -> Void) { init(appearance: SubtitleAppearance, onDismiss: @escaping () -> Void) {
self.appearance = appearance
self.onDismiss = onDismiss self.onDismiss = onDismiss
super.init() super.init()
controller.delegate = self controller.delegate = self
@@ -44,6 +46,20 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
controller.allowsPictureInPicturePlayback = false controller.allowsPictureInPicturePlayback = false
controller.transportBarIncludesTitleView = true 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` // **Hinter den Bedienelementen und ohne Fokus.** `contentOverlayView`
// liegt über dem Bild, aber unter Apples Leiste, und nimmt keine // liegt über dem Bild, aber unter Apples Leiste, und nimmt keine
// Eingaben an. `customOverlayViewController` täte beides nicht: der // Eingaben an. `customOverlayViewController` täte beides nicht: der
@@ -79,6 +95,7 @@ final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDele
zeigeAbdeckung(aufnahme.isRebuilding) zeigeAbdeckung(aufnahme.isRebuilding)
controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items( controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items(
for: aufnahme, for: aufnahme,
appearance: appearance,
onAudio: { [weak model] index in onAudio: { [weak model] index in
guard let model, let stream = model.audioStreams.first(where: { $0.index == index }) guard let model, let stream = model.audioStreams.first(where: { $0.index == index })
else { return } else { return }
+12 -2
View File
@@ -21,6 +21,8 @@ struct TVPlayerHost: UIViewControllerRepresentable {
/// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die /// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die
/// Beobachtungsbeziehung zu `@Observable`. /// Beobachtungsbeziehung zu `@Observable`.
let snapshot: TVPlayerSnapshot let snapshot: TVPlayerSnapshot
/// Der Untertitelstil — er steht im selben Menü wie die Spurwahl.
let appearance: SubtitleAppearance
let onDismiss: () -> Void let onDismiss: () -> Void
func makeUIViewController(context: Context) -> AVPlayerViewController { func makeUIViewController(context: Context) -> AVPlayerViewController {
@@ -32,7 +34,7 @@ struct TVPlayerHost: UIViewControllerRepresentable {
} }
func makeCoordinator() -> TVPlayerCoordinator { func makeCoordinator() -> TVPlayerCoordinator {
TVPlayerCoordinator(onDismiss: onDismiss) TVPlayerCoordinator(appearance: appearance, onDismiss: onDismiss)
} }
} }
@@ -49,11 +51,19 @@ struct TVPlayerSnapshot: Equatable {
let subtitle: String? let subtitle: String?
/// Der Zeitbereich einer Marke — für „Intro überspringen". /// Der Zeitbereich einer Marke — für „Intro überspringen".
let markerLabel: String? 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 /// `@MainActor`, weil das Modell es ist: gelesen wird hier zwölfmal aus
/// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur. /// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur.
@MainActor @MainActor
init(_ model: PlayerModel, offLabel: String) { init(_ model: PlayerModel, appearance: SubtitleAppearance, offLabel: String) {
subtitleStyle = appearance.style
playerIdentity = model.systemPlayer.map(ObjectIdentifier.init) playerIdentity = model.systemPlayer.map(ObjectIdentifier.init)
let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false } let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false }
audio = TrackMenu.audio( audio = TrackMenu.audio(
+19 -1
View File
@@ -35,7 +35,12 @@ struct TVPlayerScreen: View {
// ein `updateUIViewController`. // ein `updateUIViewController`.
TVPlayerHost( TVPlayerHost(
model: model, 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() } onDismiss: { playback.close() }
) )
.ignoresSafeArea() .ignoresSafeArea()
@@ -43,6 +48,19 @@ struct TVPlayerScreen: View {
ProgressView() 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 { .task {
guard model == nil else { return } guard model == nil else { return }
let frisch = PlayerModel( let frisch = PlayerModel(
+88 -36
View File
@@ -8,67 +8,119 @@ import FlixyPlayback
/// nur noch um UIKit. /// nur noch um UIKit.
@MainActor @MainActor
enum TVTrackMenuBuilder { 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( static func items(
for aufnahme: TVPlayerSnapshot, for aufnahme: TVPlayerSnapshot,
appearance: SubtitleAppearance,
onAudio: @escaping (Int?) -> Void, onAudio: @escaping (Int?) -> Void,
onSubtitle: @escaping (Int?) -> Void onSubtitle: @escaping (Int?) -> Void
) -> [UIMenuElement] { ) -> [UIMenuElement] {
var menüs: [UIMenuElement] = [] var menüs: [UIMenuElement] = []
if aufnahme.audio.count > 1 {
menüs.append(menü( // **Unser Tonmenü nur, wenn Apples eigenes es nicht kann.**
titel: String(localized: "player.audioTracks"), //
symbol: "waveform", // Wird die Datei unverändert gespielt und stimmt die Spurzahl, liegen
einträge: aufnahme.audio, // alle Spuren im Stream — dann zeigt Apple sie selbst, und zwei
gesperrt: aufnahme.isRebuilding, // Knöpfe nebeneinander wären dasselbe zweimal. Beim Umrechnen liegt
aktion: onAudio // 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)
)) ))
} }
if aufnahme.subtitles.count > 1 {
menüs.append(menü( // **Untertitel immer über uns**, auch wenn Apple es könnte: hier hängt
titel: String(localized: "player.subtitleTracks"), // der Stil mit drin, und der ist unserer — `AVTextStyleRule`s aus
symbol: "captions.bubble", // `SubtitleAppearance`, nicht die Systemeinstellung, die Apples Menü
einträge: aufnahme.subtitles, // 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, gesperrt: aufnahme.isRebuilding,
aktion: onSubtitle aktion: onSubtitle)),
größenMenü(appearance),
darstellungsMenü(appearance),
]
)) ))
}
return menüs return menüs
} }
private static func menü( // MARK: - Die Spuren
titel: String,
symbol: String, private static func einträge(
einträge: [TrackMenu.Entry], _ liste: [TrackMenu.Entry],
gesperrt: Bool, gesperrt: Bool,
aktion: @escaping (Int?) -> Void aktion: @escaping (Int?) -> Void
) -> UIMenu { ) -> [UIMenuElement] {
UIMenu( liste.map { eintrag in
title: titel,
image: UIImage(systemName: symbol),
options: [.singleSelection],
children: einträge.map { eintrag in
UIAction( UIAction(
title: eintrag.title, title: eintrag.title,
// **Vorher ansagen, nicht hinterher erklären.** Steht hier // **Vorher ansagen, nicht hinterher erklären.** Steht hier
// nichts, ist die Sekundenpause danach eine Panne; steht // nichts, ist die Sekundenpause danach eine Panne; steht es
// es hier, ist sie eine angekündigte Folge. Das ist der // hier, ist sie eine angekündigte Folge. Das ist der billigste
// billigste Teil dieser ganzen Sache und der wirksamste. // Teil dieser ganzen Sache und der wirksamste.
subtitle: eintrag.rebuilds subtitle: eintrag.rebuilds ? String(localized: "player.rebuilds") : nil,
? String(localized: "player.rebuilds") // **Keine zweite Bestellung, solange die erste läuft.** Sonst
: nil, // räumt `restart()` eine Engine ab, die eine andere gerade baut.
// **Keine zweite Bestellung, solange die erste läuft.**
// Sonst räumt `restart()` eine Engine ab, die eine andere
// gerade baut.
attributes: gesperrt ? [.disabled] : [], attributes: gesperrt ? [.disabled] : [],
state: eintrag.isSelected ? .on : .off state: eintrag.isSelected ? .on : .off
) { auslöser in ) { auslöser in
// **Sofort quittieren.** Das Menü schliesst sich beim // **Sofort quittieren.** Das Menü schliesst sich beim Drücken,
// Drücken, und darin ist kein Platz für einen Kreisel. // und darin ist kein Platz für einen Kreisel. Der Haken
// Der Haken wandert, bevor sich am Bild etwas tut. // wandert, bevor sich am Bild etwas tut.
auslöser.state = .on auslöser.state = .on
aktion(eintrag.id) 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
}
}
) )
} }