diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index bc5d521..64b895f 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -30,6 +30,7 @@ struct FlixyApp: App { @State private var playbackSettings = PlaybackSettings() @State private var languages = LanguagePreferences() @State private var quickPlay = QuickPlay() + @State private var subtitleAppearance = SubtitleAppearance() /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @@ -90,6 +91,7 @@ struct FlixyApp: App { .environment(playbackSettings) .environment(languages) .environment(quickPlay) + .environment(subtitleAppearance) .environment(network) .environment(downloads) .environment(homeLayout) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 7f95909..f172568 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -6535,6 +6535,312 @@ } } }, + "subtitleStyle.headerExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe und Farbe der Untertitel im Player." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How subtitles look in the player." + } + } + } + }, + "subtitleStyle.palette": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Darstellung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "subtitleStyle.palette.blackOnWhite": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwarz auf Weiß" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Black on white" + } + } + } + }, + "subtitleStyle.palette.whiteOnBlack": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiß auf Schwarz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "White on black" + } + } + } + }, + "subtitleStyle.palette.whiteOnClear": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiß" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "White" + } + } + } + }, + "subtitleStyle.palette.yellowOnBlack": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelb auf Schwarz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yellow on black" + } + } + } + }, + "subtitleStyle.palette.yellowOnClear": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelb" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yellow" + } + } + } + }, + "subtitleStyle.paletteExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für den Standardplayer und für Untertitel, die als Text vorliegen. Bilduntertitel von Blu-rays brennt der Server ins Bild — dort gibt es keinen Text zu gestalten, und diese Einstellungen bleiben ohne Wirkung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to the standard player and to subtitles that come as text. Image subtitles from Blu-rays are burned into the picture by the server — there is no text to style, so these settings have no effect there." + } + } + } + }, + "subtitleStyle.paletteHeader": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schrift und Hintergrund" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Text and background" + } + } + } + }, + "subtitleStyle.previewHeader": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + } + } + }, + "subtitleStyle.previewText": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "So sehen deine Untertitel aus." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This is how your subtitles look." + } + } + } + }, + "subtitleStyle.size": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Size" + } + } + } + }, + "subtitleStyle.size.huge": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sehr groß" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Very large" + } + } + } + }, + "subtitleStyle.size.large": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Large" + } + } + } + }, + "subtitleStyle.size.normal": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + } + } + }, + "subtitleStyle.size.small": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Small" + } + } + } + }, + "subtitleStyle.sizeExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bezogen auf die Vorgabe des Players, nicht auf eine feste Schriftgröße — die Untertitel bleiben damit auf jedem Bildschirm gleich gut lesbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relative to the player’s own default rather than a fixed point size, so subtitles stay readable on any screen." + } + } + } + }, + "subtitleStyle.title": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles" + } + } + } + }, "technical.audio": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/SubtitleAppearance.swift b/Flixy/Session/SubtitleAppearance.swift new file mode 100644 index 0000000..4dcfbef --- /dev/null +++ b/Flixy/Session/SubtitleAppearance.swift @@ -0,0 +1,44 @@ +import Foundation +import Observation +import FlixyPlayback + +/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg. +/// +/// Eigener Typ statt zweier Felder an `PlaybackSettings`, aus demselben Grund +/// wie bei `LanguagePreferences`: dort geht es um Qualität und Leitung, hier +/// um Darstellung. Zwei Fragen, zwei Orte. +/// +/// **Die Werte selbst liegen in `SubtitleStyle`**, also im prüfbaren Teil. +/// Hier steht nur, wie sie einen Neustart überleben. +@MainActor +@Observable +final class SubtitleAppearance { + private enum Key { + static let size = "flixy.subtitles.size" + static let palette = "flixy.subtitles.palette" + } + + private let defaults: UserDefaults + + var size: SubtitleStyle.Size { + didSet { defaults.set(size.rawValue, forKey: Key.size) } + } + + var palette: SubtitleStyle.Palette { + didSet { defaults.set(palette.rawValue, forKey: Key.palette) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + // Über `object(forKey:)`, nicht über `integer(forKey:)`: das gäbe für + // „nie gesetzt" eine Null zurück, und die ist keine gültige Stufe. + self.size = (defaults.object(forKey: Key.size) as? Int) + .flatMap(SubtitleStyle.Size.init(rawValue:)) ?? .normal + self.palette = defaults.string(forKey: Key.palette) + .flatMap(SubtitleStyle.Palette.init(rawValue:)) ?? .whiteOnClear + } + + var style: SubtitleStyle { + SubtitleStyle(size: size, palette: palette) + } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 5f2159a..4851954 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -17,6 +17,7 @@ struct RootView: View { @Environment(PlaybackSettings.self) private var settings @Environment(LanguagePreferences.self) private var languages @Environment(QuickPlay.self) private var quickPlay + @Environment(SubtitleAppearance.self) private var subtitleAppearance @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @@ -201,6 +202,7 @@ struct RootView: View { policy: settings.policy(for: network), settings: settings, languages: languages, + subtitles: subtitleAppearance, // **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei // Jellyfin immer `nil` — damit galt dort jede Wiedergabe als // entfernt, auch zuhause am Kabel, und „Auto" hätte ein diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 80a73ea..18c6423 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -178,6 +178,7 @@ final class PlayerModel { private let policy: PlaybackPolicy private let settings: PlaybackSettings private let languages: LanguagePreferences + private let subtitles: SubtitleAppearance private let isLocalConnection: Bool /// Wie die Verbindung in der Diagnose heißt. /// @@ -410,6 +411,7 @@ final class PlayerModel { policy: PlaybackPolicy, settings: PlaybackSettings, languages: LanguagePreferences, + subtitles: SubtitleAppearance, isLocalConnection: Bool, connectionDescription: String?, unavailableMessage: String @@ -422,6 +424,7 @@ final class PlayerModel { self.policy = policy self.settings = settings self.languages = languages + self.subtitles = subtitles self.isLocalConnection = isLocalConnection self.connectionDescription = connectionDescription self.unavailableMessage = unavailableMessage @@ -665,6 +668,9 @@ final class PlayerModel { self.engine = engine attachSurface(to: engine) observe(engine) + // **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist + // ungestaltet, und der erste Untertitel käme sonst in der Vorgabe. + engine.applySubtitleStyle(subtitles.style) do { diagnostics.note("Engine öffnet Quelle") diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 3fd4105..7550205 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -17,6 +17,7 @@ struct PlayerView: View { @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings + @Environment(SubtitleAppearance.self) private var subtitleAppearance @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @@ -273,6 +274,13 @@ struct PlayerView: View { showsControls = true scheduleControlsHide() } + // **Mitten im Film.** Eine Einstellung, die erst beim nächsten + // Titel wirkt, hält jeder für kaputt — man stellt um und sieht + // nichts. `SubtitleStyle` ist vergleichbar, das feuert nur bei einer + // echten Änderung. + .onChange(of: subtitleAppearance.style) { _, neu in + model.engine?.applySubtitleStyle(neu) + } .task { scheduleControlsHide() } .onDisappear { hideTask?.cancel() } } diff --git a/Flixy/Views/Settings/SettingsChrome.swift b/Flixy/Views/Settings/SettingsChrome.swift index 2f4a838..4c76ecc 100644 --- a/Flixy/Views/Settings/SettingsChrome.swift +++ b/Flixy/Views/Settings/SettingsChrome.swift @@ -63,6 +63,7 @@ enum SettingsPalette { static let home = Color.green static let playback = Color.pink static let languages = Color.teal + static let subtitles = Color.yellow static let downloads = Color.indigo static let diagnostics = Color.orange } diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 0f63db4..4927632 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -51,6 +51,9 @@ struct SettingsView: View { link("languages.title", symbol: "captions.bubble", colour: SettingsPalette.languages) { LanguagesView() } + link("subtitleStyle.title", symbol: "textformat.size", colour: SettingsPalette.subtitles) { + SubtitleAppearanceView() + } link("sidebar.downloads", symbol: "arrow.down.circle", colour: SettingsPalette.downloads) { DownloadSettingsView() } diff --git a/Flixy/Views/Settings/SubtitleAppearanceView.swift b/Flixy/Views/Settings/SubtitleAppearanceView.swift new file mode 100644 index 0000000..84fe545 --- /dev/null +++ b/Flixy/Views/Settings/SubtitleAppearanceView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback + +/// Wie Untertitel aussehen sollen. +struct SubtitleAppearanceView: View { + @Environment(SubtitleAppearance.self) private var appearance + @Environment(\.flixyAccent) private var accent + + var body: some View { + @Bindable var appearance = appearance + + Form { + SettingsHeader( + symbol: "textformat.size", + colour: SettingsPalette.subtitles, + title: "subtitleStyle.title", + explanation: "subtitleStyle.headerExplanation" + ) + + Section { + preview + } header: { + Text("subtitleStyle.previewHeader") + } + + Section { + Picker("subtitleStyle.size", selection: $appearance.size) { + ForEach(SubtitleStyle.Size.allCases) { size in + Text(size.label).tag(size) + } + } + } footer: { + Text("subtitleStyle.sizeExplanation") + } + + Section { + Picker("subtitleStyle.palette", selection: $appearance.palette) { + ForEach(SubtitleStyle.Palette.allCases) { palette in + Text(palette.label).tag(palette) + } + } + } header: { + Text("subtitleStyle.paletteHeader") + } footer: { + // Die Grenze gehört hierher, nicht nur in einen Kommentar: + // bei eingebrannten Untertiteln tut jede dieser Einstellungen + // sichtbar nichts, und ohne Erklärung sieht das nach einem + // Fehler aus. + Text("subtitleStyle.paletteExplanation") + } + } + .tint(accent) + .flixyForm() + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("subtitleStyle.title")) + .flixyInlineTitle() + } + + /// Ein Beispielsatz über grauer Fläche. + /// + /// **Eine Nachbildung, nicht AVFoundations Renderer.** Sie zeigt die + /// Wirkung der Einstellung, nicht das Bild auf den Punkt genau — wer + /// später zwei Pixel „repariert", repariert die falsche Sache. + private var preview: some View { + ZStack { + // Mittelgrau: auf Weiss wäre weisse Schrift unsichtbar, auf + // Schwarz schwarze. Ein Grauton zeigt beide ehrlich. + LinearGradient( + colors: [Color(white: 0.35), Color(white: 0.18)], + startPoint: .top, + endPoint: .bottom + ) + + Text("subtitleStyle.previewText") + .font(.system(size: 15 * CGFloat(appearance.size.rawValue) / 100, weight: .medium)) + .foregroundStyle(appearance.style.foreground.color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(appearance.style.background.color) + .shadow( + color: appearance.style.wantsEdgeShadow ? .black.opacity(0.9) : .clear, + radius: 2, + y: 1 + ) + .padding(FlixySpacing.regular) + } + .frame(height: 96) + .clipShape(.rect(cornerRadius: FlixyRadius.card)) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } +} + +extension SubtitleStyle.RGBA { + var color: Color { + 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/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index c26f1cc..030198c 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -77,6 +77,7 @@ public final class AVPlayerEngine: VideoPlayerEngine { /// Aus `playbackStalledNotification` und **nicht** aus dem /// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll /// nur bei Streamsn über das Netz. Beides zu addieren zählte doppelt. + private var subtitleStyle: SubtitleStyle? private var stallCount = 0 /// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`. private var accumulatedStall: Duration = .zero @@ -125,6 +126,12 @@ public final class AVPlayerEngine: VideoPlayerEngine { ) } + // **Auch am frischen Gegenstand.** Er beginnt ungestaltet, und ohne + // diese Zeile fiele jede weitere Folge auf die Vorgabe zurück. + if let subtitleStyle { + item.textStyleRules = subtitleStyle.textStyleRules + } + player.replaceCurrentItem(with: item) observe(item: item) @@ -220,6 +227,17 @@ public final class AVPlayerEngine: VideoPlayerEngine { select(track, in: .legible) } + /// Gestaltet die Untertitel — sofort, und auch beim nächsten Film. + /// + /// **Beides ist nötig.** Ein frisches `AVPlayerItem` beginnt ungestaltet; + /// wer nur das laufende anfasst, sieht die nächste Folge wieder in der + /// Vorgabe. Deshalb wird der Wunsch gemerkt und in `load(_:)` erneut + /// angelegt. + public func applySubtitleStyle(_ style: SubtitleStyle) { + subtitleStyle = style + player.currentItem?.textStyleRules = style.textStyleRules + } + /// Was gerade über die Leitung kommt. /// /// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle+AVFoundation.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle+AVFoundation.swift new file mode 100644 index 0000000..a2a7341 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle+AVFoundation.swift @@ -0,0 +1,45 @@ +import AVFoundation +import CoreMedia +import Foundation + +public extension SubtitleStyle { + /// Die Regeln, wie AVFoundation sie erwartet. + /// + /// **Vier Fallen stecken hier drin, und jede ist lautlos:** + /// + /// 1. **Die Farbe ist ein Feld aus vier Zahlen von 0 bis 1, Alpha + /// zuerst** — A, R, G, B. Kein `CGColor`, kein `UIColor`; ein solcher + /// wird ohne Fehlermeldung ignoriert. + /// 2. **Die Grösse ist ein Prozentwert**, hundert heisst unverändert. + /// `BaseFontSizePercentageRelativeToVideoHeight` **nicht** zusätzlich + /// setzen — die beiden beziehen sich auf Verschiedenes und streiten + /// sich. + /// 3. **Den Hintergrund immer setzen, auch für „durchsichtig".** Ihn + /// wegzulassen ist nicht dasselbe wie Alpha null: dann gilt, was der + /// Untertitel selbst mitbringt, und Jellyfins WebVTT bringt oft einen + /// schwarzen Kasten mit. + /// 4. **`AVTextStyleRule(textMarkupAttributes:)` kann fehlschlagen.** Ein + /// falscher Schlüssel ergibt `nil` — und damit gar keine Regel, ohne + /// dass irgendwo etwas auffiele. Genau dafür gibt es den Test. + var textStyleRules: [AVTextStyleRule] { + var attribute: [String: Any] = [ + kCMTextMarkupAttribute_ForegroundColorARGB as String: foreground.argb, + kCMTextMarkupAttribute_BackgroundColorARGB as String: background.argb, + kCMTextMarkupAttribute_RelativeFontSize as String: Double(size.rawValue), + ] + + if wantsEdgeShadow { + attribute[kCMTextMarkupAttribute_CharacterEdgeStyle as String] = + kCMTextMarkupCharacterEdgeStyle_DropShadow as String + } + + return [AVTextStyleRule(textMarkupAttributes: attribute)].compactMap(\.self) + } +} + +extension SubtitleStyle.RGBA { + /// Alpha zuerst — die Reihenfolge, die CoreMedia verlangt. + var argb: [NSNumber] { + [alpha, red, green, blue].map(NSNumber.init(value:)) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle.swift new file mode 100644 index 0000000..bb5c3f0 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/SubtitleStyle.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Wie Untertitel aussehen sollen. +/// +/// **Warum hier und nicht im App-Ziel.** Weil es prüfbar sein muss: die +/// Übersetzung nach AVFoundation ist eine Tabelle aus Schlüsseln, bei der ein +/// Tippfehler lautlos dazu führt, dass **gar nichts** gesetzt wird. Ein Test +/// findet das, ein Blick auf den Bildschirm nicht — dort sieht „keine Regel" +/// genauso aus wie „Regel ohne Wirkung". +public struct SubtitleStyle: Sendable, Equatable { + /// Eine Farbe, wie CoreMedia sie versteht: vier Anteile von 0 bis 1. + public struct RGBA: Sendable, Equatable { + public let red: Double + public let green: Double + public let blue: Double + public let alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + static let white = RGBA(red: 1, green: 1, blue: 1) + static let black = RGBA(red: 0, green: 0, blue: 0) + static let yellow = RGBA(red: 1, green: 0.85, blue: 0.2) + static let clear = RGBA(red: 0, green: 0, blue: 0, alpha: 0) + /// Nicht ganz deckend: ein voll schwarzer Kasten wirkt wie ein Loch + /// im Bild. + static let box = RGBA(red: 0, green: 0, blue: 0, alpha: 0.75) + } + + /// Die Grösse, **relativ** zur Vorgabe des Players. + /// + /// Ein Prozentwert, keine Punktgrösse: hundert heisst unverändert. So + /// bleibt die Einstellung richtig, egal wie gross das Bild ist. + public enum Size: Int, CaseIterable, Identifiable, Sendable, Codable { + case small = 75 + case normal = 100 + case large = 130 + case huge = 175 + + public var id: Int { rawValue } + } + + /// Schrift und Kasten als **Paar**, nicht als zwei Regler. + /// + /// Unabhängige Achsen liessen unlesbare Mischungen zu — Schwarz auf + /// durchsichtig über einer Nachtszene ist eine gültige Einstellung und + /// eine wertlose. Diese fünf decken ab, was man tatsächlich will. + public enum Palette: String, CaseIterable, Identifiable, Sendable, Codable { + case whiteOnClear + case whiteOnBlack + case blackOnWhite + case yellowOnClear + case yellowOnBlack + + public var id: String { rawValue } + } + + public var size: Size + public var palette: Palette + + public init(size: Size = .normal, palette: Palette = .whiteOnClear) { + self.size = size + self.palette = palette + } + + public var foreground: RGBA { + switch palette { + case .whiteOnClear, .whiteOnBlack: .white + case .blackOnWhite: .black + case .yellowOnClear, .yellowOnBlack: .yellow + } + } + + public var background: RGBA { + switch palette { + case .whiteOnClear, .yellowOnClear: .clear + case .whiteOnBlack, .yellowOnBlack: .box + case .blackOnWhite: RGBA(red: 1, green: 1, blue: 1, alpha: 0.85) + } + } + + /// Ob ein Schlagschatten nötig ist. + /// + /// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer + /// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten nur + /// Unruhe. + public var wantsEdgeShadow: Bool { + switch palette { + case .whiteOnClear, .yellowOnClear: true + case .whiteOnBlack, .blackOnWhite, .yellowOnBlack: false + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift index bd3c2bf..0a9a834 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -90,6 +90,14 @@ public protocol VideoPlayerEngine: AnyObject { /// `nil` schaltet die Untertitel ab. func selectSubtitleTrack(_ track: MediaTrack?) + /// Wie Untertitel dargestellt werden sollen. + /// + /// **Darf jederzeit gesetzt werden, auch mitten im Film.** Genau deshalb + /// steht es hier und nicht an `PlaybackSource`: die wird einmal beim + /// Öffnen gelesen, und eine Einstellung, die erst beim nächsten Film + /// wirkt, hält jeder für kaputt. + func applySubtitleStyle(_ style: SubtitleStyle) + /// Was gerade tatsächlich über die Leitung kommt. /// /// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der @@ -221,6 +229,13 @@ extension VideoPlayerEngine { /// nichts zu tun — bei einer Datei gibt es keine Stufen. public func setPeakBitrate(_ kilobits: Int?) {} + /// Wer Untertitel nicht gestalten kann, lässt sie, wie sie sind. + /// + /// VLC kann es im Grundsatz, aber nur über Angaben, die beim Bau der + /// Bibliothek gesetzt werden, global gelten und sich mitten im Film nicht + /// ändern lassen — ein zweiter Weg, der vom ersten abdriftet. + public func applySubtitleStyle(_ style: SubtitleStyle) {} + /// Wer nichts dazu sagt, kann es nicht. public var supportsAirPlay: Bool { false } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/SubtitleStyleTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/SubtitleStyleTests.swift new file mode 100644 index 0000000..0cfbb72 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/SubtitleStyleTests.swift @@ -0,0 +1,143 @@ +import AVFoundation +import CoreMedia +import Foundation +import Testing +@testable import FlixyPlayback + +@Suite("Aussehen der Untertitel") +struct SubtitleStyleTests { + + private func attributes(_ style: SubtitleStyle) -> [String: Any]? { + style.textStyleRules.first?.textMarkupAttributes + } + + private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? { + (attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue) + } + + // MARK: - Dass überhaupt etwas herauskommt + + /// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von + /// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt + /// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht + /// „keine Regel" genauso aus wie „Regel ohne Wirkung". + @Test("Jedes Paar ergibt genau eine Regel") + func everyPaletteProducesOneRule() { + for palette in SubtitleStyle.Palette.allCases { + for size in SubtitleStyle.Size.allCases { + let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules + #expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln") + } + } + } + + // MARK: - Die Farben + + /// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an + /// dieser Stelle, und einer ohne Fehlermeldung. + @Test("Die Farbe steht als A, R, G, B da") + func colourOrderIsARGB() { + let gelb = SubtitleStyle(palette: .yellowOnBlack) + let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB) + + #expect(werte?.count == 4) + #expect(werte?[0] == 1) // Alpha + #expect(werte?[1] == 1) // Rot + #expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb + } + + @Test("Alle Anteile liegen zwischen null und eins") + func coloursAreInRange() { + for palette in SubtitleStyle.Palette.allCases { + let stil = SubtitleStyle(palette: palette) + for schlüssel in [ + kCMTextMarkupAttribute_ForegroundColorARGB, + kCMTextMarkupAttribute_BackgroundColorARGB, + ] { + let werte = colour(stil, schlüssel) ?? [] + #expect(werte.count == 4, "\(palette): \(werte.count) Anteile") + #expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)") + } + } + } + + @Test("Weiss auf Schwarz ist weiss auf schwarz") + func whiteOnBlack() { + let stil = SubtitleStyle(palette: .whiteOnBlack) + #expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1]) + + let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? [] + #expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten + #expect(grund.dropFirst().allSatisfy { $0 == 0 }) + } + + @Test("Schwarz auf Weiss ist die Umkehrung") + func blackOnWhite() { + let stil = SubtitleStyle(palette: .blackOnWhite) + #expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0]) + #expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5) + } + + /// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel + /// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT + /// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne + /// Kasten" wirkungslos, und niemand wüsste warum. + @Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen") + func transparentIsStated() { + for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] { + let stil = SubtitleStyle(palette: palette) + let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) + + #expect(grund != nil, "\(palette): der Hintergrund fehlt ganz") + #expect(grund?.first == 0, "\(palette): Alpha ist nicht null") + } + } + + // MARK: - Die Grösse + + /// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und + /// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was + /// der Untertitel mitbringt. + @Test("Die Grösse ist ein Prozentwert, auch bei hundert") + func sizeIsAPercentage() { + for size in SubtitleStyle.Size.allCases { + let wert = attributes(SubtitleStyle(size: size))?[ + kCMTextMarkupAttribute_RelativeFontSize as String + ] as? Double + #expect(wert == Double(size.rawValue)) + } + } + + /// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu + /// setzen liesse sie sich streiten. + @Test("Die andere Grössenangabe bleibt weg") + func onlyOneSizeKey() { + let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:] + #expect(gesetzt[ + kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String + ] == nil) + } + + // MARK: - Der Rand + + /// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer + /// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe. + @Test("Den Schatten gibt es nur ohne Kasten") + func edgeShadowOnlyWithoutBox() { + for palette in SubtitleStyle.Palette.allCases { + let stil = SubtitleStyle(palette: palette) + let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String] + #expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)") + } + } + + // MARK: - Die Vorgabe + + @Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse") + func defaults() { + let stil = SubtitleStyle() + #expect(stil.size == .normal) + #expect(stil.palette == .whiteOnClear) + #expect(stil.wantsEdgeShadow) + } +}