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:
Scarriffle
2026-09-28 21:22:22 +02:00
co-authored by Claude Opus 5
parent a1ff5cb7bb
commit 0815432efd
14 changed files with 815 additions and 0 deletions
+2
View File
@@ -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)
+306
View File
@@ -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": {
+44
View File
@@ -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)
}
}
+2
View File
@@ -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
+6
View File
@@ -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")
+8
View File
@@ -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
}
+3
View File
@@ -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"
}
}
}