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
@@ -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 }
}