diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index cd41544..6ebc49c 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { diff --git a/Flixy/Session/SubtitleStyle+Labels.swift b/Flixy/Session/SubtitleStyle+Labels.swift new file mode 100644 index 0000000..ad0e4cd --- /dev/null +++ b/Flixy/Session/SubtitleStyle+Labels.swift @@ -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") + } + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index a15eeb6..ca685e2 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -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 } diff --git a/Flixy/Views/Settings/SubtitleAppearanceView.swift b/Flixy/Views/Settings/SubtitleAppearanceView.swift index 84fe545..0e80501 100644 --- a/Flixy/Views/Settings/SubtitleAppearanceView.swift +++ b/Flixy/Views/Settings/SubtitleAppearanceView.swift @@ -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" - } - } -} diff --git a/FlixyTV/Views/Home/TVHeroSection.swift b/FlixyTV/Views/Home/TVHeroSection.swift index bd90130..125b9cf 100644 --- a/FlixyTV/Views/Home/TVHeroSection.swift +++ b/FlixyTV/Views/Home/TVHeroSection.swift @@ -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 { diff --git a/FlixyTV/Views/Player/TVPlayerCoordinator.swift b/FlixyTV/Views/Player/TVPlayerCoordinator.swift index 77a611f..af82040 100644 --- a/FlixyTV/Views/Player/TVPlayerCoordinator.swift +++ b/FlixyTV/Views/Player/TVPlayerCoordinator.swift @@ -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? - 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 } diff --git a/FlixyTV/Views/Player/TVPlayerHost.swift b/FlixyTV/Views/Player/TVPlayerHost.swift index 22b86cb..ffb9874 100644 --- a/FlixyTV/Views/Player/TVPlayerHost.swift +++ b/FlixyTV/Views/Player/TVPlayerHost.swift @@ -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( diff --git a/FlixyTV/Views/Player/TVPlayerScreen.swift b/FlixyTV/Views/Player/TVPlayerScreen.swift index 6c874cc..1853c70 100644 --- a/FlixyTV/Views/Player/TVPlayerScreen.swift +++ b/FlixyTV/Views/Player/TVPlayerScreen.swift @@ -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( diff --git a/FlixyTV/Views/Player/TVTrackMenuBuilder.swift b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift index 513a2e5..2de3ed7 100644 --- a/FlixyTV/Views/Player/TVTrackMenuBuilder.swift +++ b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift @@ -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 } } )