From 150ebf42a497f5babb162b8204c0a44a64db6275 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 28 Jul 2026 21:33:32 +0200 Subject: [PATCH] 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. --- Yattee/Localizable.xcstrings | 100 +++++++++ .../Settings/SubtitlesSettingsView.swift | 208 +++++++++++++++--- 2 files changed, 279 insertions(+), 29 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 592d9244..b3de81f0 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Views/Settings/SubtitlesSettingsView.swift b/Yattee/Views/Settings/SubtitlesSettingsView.swift index e5584fba..f4110df6 100644 --- a/Yattee/Views/Settings/SubtitlesSettingsView.swift +++ b/Yattee/Views/Settings/SubtitlesSettingsView.swift @@ -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) - } - - 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.textColor", + color: Binding( + get: { settings.textColor }, + set: { + settings.textColor = $0 + saveSettings() + } + ) ) + + 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) -> 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 { + 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 {