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": {
"extractionState": "manual",
"localizations": {
@@ -1569,7 +1586,7 @@
}
},
"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",
"localizations": {
"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": {
"extractionState": "manual",
"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? {
guard item.kind == .episode else { return nil }
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
}
@@ -98,28 +98,3 @@ extension SubtitleStyle.RGBA {
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? {
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 {
+18 -1
View File
@@ -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 }
+12 -2
View File
@@ -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(
+19 -1
View File
@@ -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(
+93 -41
View File
@@ -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
}
}
)