Untertitel lassen sich jetzt einstellen
Groesse in vier Stufen, Schrift und Hintergrund als fuenf feste Paare — Weiss, Weiss auf Schwarz, Schwarz auf Weiss, Gelb, Gelb auf Schwarz. Feste Paare statt zweier Regler, weil unabhaengige Achsen unlesbare Mischungen zulassen: Schwarz auf durchsichtig ueber einer Nachtszene ist eine gueltige Einstellung und eine wertlose. Die Uebersetzung nach AVFoundation ist eine Tabelle aus Schluesseln, bei der jeder Fehler lautlos ist — ein falscher Schluessel laesst den Initialisierer `nil` zurueckgeben, und dann gibt es gar keine Regel. Auf dem Bildschirm sieht „keine Regel\" genauso aus wie „Regel ohne Wirkung\". Zehn Tests decken das ab, darunter die drei Fallen: Alpha zuerst in der Farbe, Prozentwert statt Punktgroesse, und der Hintergrund auch fuer „durchsichtig\" ausdruecklich gesetzt statt weggelassen — weggelassen gilt sonst, was Jellyfins WebVTT selbst mitbringt, und das ist oft ein schwarzer Kasten. Als Protokollmethode, nicht als Feld an `PlaybackSource`: die wird einmal beim Oeffnen gelesen, und eine Einstellung, die erst beim naechsten Film wirkt, haelt jeder fuer kaputt. Angewandt wird an zwei Stellen — am laufenden Gegenstand und am frisch angelegten. Nur das erste zu tun hiesse, dass die naechste Folge wieder in der Vorgabe erscheint. VLC erbt die leere Vorgabe. Die Einstellungsseite sagt beides: dass es fuer den Standardplayer gilt, und dass eingebrannte Bilduntertitel von Blu-rays sich nicht gestalten lassen, weil es dort keinen Text gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1ff5cb7bb
commit
0815432efd
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user