Fix #955: make subtitle appearance settings adjustable on tvOS

Text color, border color, background color, border size, and bottom
margin were rendered as static rows on tvOS (ColorPicker and Slider are
unavailable there), so the focus engine skipped over them and they could
never be changed.

Replace them with menu pickers following the existing font size pattern:
- Text/border/background color: 9 preset colors with a swatch in the
  row label; stored values snap to the nearest preset
- Background opacity: 25-100% steps (iOS parity, where ColorPicker
  supports opacity for the background)
- Border size: 0.0-5.0 in 0.5 steps
- Bottom margin: 0-50% in 5% steps

Verified on tvOS simulator: rows are focusable, selections persist and
are rendered by MPV during playback.
This commit is contained in:
Arkadiusz Fal
2026-07-28 21:33:32 +02:00
parent cd01c08b5a
commit 150ebf42a4
2 changed files with 279 additions and 29 deletions

View File

@@ -15007,6 +15007,16 @@
}
}
},
"settings.subtitles.backgroundOpacity" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Background Opacity"
}
}
}
},
"settings.subtitles.bold" : {
"localizations" : {
"en" : {
@@ -15047,6 +15057,96 @@
}
}
},
"settings.subtitles.color.black" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Black"
}
}
}
},
"settings.subtitles.color.blue" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Blue"
}
}
}
},
"settings.subtitles.color.cyan" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cyan"
}
}
}
},
"settings.subtitles.color.green" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Green"
}
}
}
},
"settings.subtitles.color.magenta" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Magenta"
}
}
}
},
"settings.subtitles.color.orange" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Orange"
}
}
}
},
"settings.subtitles.color.red" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Red"
}
}
}
},
"settings.subtitles.color.white" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "White"
}
}
}
},
"settings.subtitles.color.yellow" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Yellow"
}
}
}
},
"settings.subtitles.colorsSection" : {
"localizations" : {
"en" : {

View File

@@ -86,26 +86,34 @@ struct SubtitlesSettingsView: View {
private var colorsSection: some View {
SettingsFormSection("settings.subtitles.colorsSection") {
#if os(tvOS)
HStack {
Text(String(localized: "settings.subtitles.textColor"))
Spacer()
Circle()
.fill(settings.textColor.color)
.frame(width: 24, height: 24)
colorPresetPicker(
"settings.subtitles.textColor",
color: Binding(
get: { settings.textColor },
set: {
settings.textColor = $0
saveSettings()
}
HStack {
Text(String(localized: "settings.subtitles.borderColor"))
Spacer()
Circle()
.fill(settings.borderColor.color)
.frame(width: 24, height: 24)
}
LabeledContent(
String(localized: "settings.subtitles.borderSize"),
value: String(format: "%.1f", settings.borderSize)
)
)
colorPresetPicker(
"settings.subtitles.borderColor",
color: Binding(
get: { settings.borderColor },
set: {
settings.borderColor = $0
saveSettings()
}
)
)
PlatformMenuPicker(String(localized: "settings.subtitles.borderSize"), selection: $settings.borderSize) {
ForEach(Array(stride(from: 0.0, through: 5.0, by: 0.5)), id: \.self) { size in
Text(String(format: "%.1f", size)).tag(size)
}
}
.onChange(of: settings.borderSize) { _, _ in saveSettings() }
#else
ColorPicker(
String(localized: "settings.subtitles.textColor"),
@@ -152,12 +160,30 @@ struct SubtitlesSettingsView: View {
#if os(tvOS)
if settings.showBackground {
HStack {
Text(String(localized: "settings.subtitles.backgroundColor"))
Spacer()
Circle()
.fill(settings.backgroundColor.color)
.frame(width: 24, height: 24)
colorPresetPicker(
"settings.subtitles.backgroundColor",
color: Binding(
get: { settings.backgroundColor },
set: {
// Preserve the stored opacity; it's controlled by the opacity picker below.
settings.backgroundColor = CodableColor(
red: $0.red,
green: $0.green,
blue: $0.blue,
opacity: settings.backgroundColor.opacity
)
saveSettings()
}
)
)
PlatformMenuPicker(
String(localized: "settings.subtitles.backgroundOpacity"),
selection: backgroundOpacity
) {
ForEach([0.25, 0.5, 0.75, 1.0], id: \.self) { opacity in
Text(verbatim: "\(Int(opacity * 100))%").tag(opacity)
}
}
}
#else
@@ -201,10 +227,12 @@ struct SubtitlesSettingsView: View {
private var positionSection: some View {
SettingsFormSection("settings.subtitles.positionSection", footer: "settings.subtitles.positionFooter") {
#if os(tvOS)
LabeledContent(
String(localized: "settings.subtitles.positionSection"),
value: "\(settings.bottomMargin)"
)
PlatformMenuPicker(String(localized: "settings.subtitles.positionSection"), selection: $settings.bottomMargin) {
ForEach(Array(stride(from: 0, through: 50, by: 5)), id: \.self) { margin in
Text(verbatim: "\(margin)%").tag(margin)
}
}
.onChange(of: settings.bottomMargin) { _, _ in saveSettings() }
#else
Stepper(
String(localized: "settings.subtitles.bottomMargin \(settings.bottomMargin)"),
@@ -234,6 +262,51 @@ struct SubtitlesSettingsView: View {
}
}
// MARK: - tvOS Helpers
#if os(tvOS)
/// A menu picker over the preset palette, with a swatch of the current color in the label.
private func colorPresetPicker(_ titleKey: String.LocalizationValue, color: Binding<CodableColor>) -> some View {
PlatformMenuPicker(
selection: Binding(
get: { SubtitleColorPreset.nearest(to: color.wrappedValue) },
set: { color.wrappedValue = $0.codableColor }
)
) {
ForEach(SubtitleColorPreset.allCases, id: \.self) { preset in
Text(preset.displayName).tag(preset)
}
} label: {
HStack {
Text(String(localized: titleKey))
Circle()
.fill(color.wrappedValue.color)
.frame(width: 24, height: 24)
}
}
}
/// Background opacity snapped to the picker's preset steps.
private var backgroundOpacity: Binding<Double> {
Binding(
get: {
[0.25, 0.5, 0.75, 1.0].min {
abs($0 - settings.backgroundColor.opacity) < abs($1 - settings.backgroundColor.opacity)
} ?? 0.75
},
set: {
settings.backgroundColor = CodableColor(
red: settings.backgroundColor.red,
green: settings.backgroundColor.green,
blue: settings.backgroundColor.blue,
opacity: $0
)
saveSettings()
}
)
}
#endif
// MARK: - Helpers
private func saveSettings() {
@@ -246,6 +319,83 @@ struct SubtitlesSettingsView: View {
}
}
// MARK: - tvOS Color Presets
#if os(tvOS)
/// Preset colors for subtitle settings on tvOS, where ColorPicker is unavailable.
private enum SubtitleColorPreset: String, CaseIterable {
case white
case yellow
case orange
case red
case magenta
case blue
case cyan
case green
case black
var displayName: String {
switch self {
case .white:
return String(localized: "settings.subtitles.color.white")
case .yellow:
return String(localized: "settings.subtitles.color.yellow")
case .orange:
return String(localized: "settings.subtitles.color.orange")
case .red:
return String(localized: "settings.subtitles.color.red")
case .magenta:
return String(localized: "settings.subtitles.color.magenta")
case .blue:
return String(localized: "settings.subtitles.color.blue")
case .cyan:
return String(localized: "settings.subtitles.color.cyan")
case .green:
return String(localized: "settings.subtitles.color.green")
case .black:
return String(localized: "settings.subtitles.color.black")
}
}
var codableColor: CodableColor {
switch self {
case .white:
return CodableColor(red: 1, green: 1, blue: 1)
case .yellow:
return CodableColor(red: 1, green: 1, blue: 0)
case .orange:
return CodableColor(red: 1, green: 0.58, blue: 0)
case .red:
return CodableColor(red: 1, green: 0.23, blue: 0.19)
case .magenta:
return CodableColor(red: 1, green: 0.18, blue: 0.83)
case .blue:
return CodableColor(red: 0.04, green: 0.52, blue: 1)
case .cyan:
return CodableColor(red: 0.25, green: 0.78, blue: 0.98)
case .green:
return CodableColor(red: 0.16, green: 0.86, blue: 0.25)
case .black:
return CodableColor(red: 0, green: 0, blue: 0)
}
}
/// The preset closest to the given color (ignoring opacity).
static func nearest(to color: CodableColor) -> SubtitleColorPreset {
allCases.min { lhs, rhs in
distance(lhs.codableColor, color) < distance(rhs.codableColor, color)
} ?? .white
}
private static func distance(_ a: CodableColor, _ b: CodableColor) -> Double {
let dr = a.red - b.red
let dg = a.green - b.green
let db = a.blue - b.blue
return dr * dr + dg * dg + db * db
}
}
#endif
// MARK: - Preview
#Preview {