From b47888fe04b531d3a22ba6ef47cdcdb7f792b5bd Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 4 Jul 2026 23:57:59 +0200 Subject: [PATCH] Add custom accent color with system color picker Add a Custom swatch to the appearance settings accent color grid, backed by a hex value stored in settings (synced via iCloud like the rest). iOS uses the native ColorPicker wheel; macOS uses a circular swatch that opens NSColorPanel, since the SwiftUI color well looks out of place among the circles. All accent consumers now read the resolved color through SettingsManager.resolvedAccentColor. The indigo preset is retired from the grid but kept in the enum, so users who selected it keep their color until they pick another one. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o --- Yattee/Core/Settings/SettingsKey.swift | 1 + .../Settings/SettingsManager+General.swift | 18 +++ Yattee/Core/Settings/SettingsTypes.swift | 12 +- Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 10 ++ Yattee/Views/Channel/ChannelView.swift | 2 +- .../Views/Components/BookmarkCardView.swift | 2 +- Yattee/Views/Components/BookmarkRowView.swift | 2 +- .../Views/Components/BookmarkTagsView.swift | 2 +- Yattee/Views/Components/CommentView.swift | 2 +- Yattee/Views/Components/TagInputView.swift | 2 +- Yattee/Views/Downloads/DownloadsView.swift | 2 +- Yattee/Views/Home/HomeShortcutCardView.swift | 2 +- Yattee/Views/Home/HomeShortcutRowView.swift | 2 +- Yattee/Views/Home/HomeShortcutStyleView.swift | 2 +- Yattee/Views/Home/HomeView.swift | 4 +- Yattee/Views/Player/ExpandedPlayerSheet.swift | 2 +- .../Views/Player/FloatingDetailsPanel.swift | 2 +- Yattee/Views/Player/PanelPinButton.swift | 2 +- .../Views/Player/PortraitDetailsPanel.swift | 2 +- .../Settings/AppearanceSettingsView.swift | 114 +++++++++++++++++- .../Subscriptions/SubscriptionsView.swift | 2 +- Yattee/Views/Video/VideoInfoView.swift | 2 +- Yattee/YatteeApp.swift | 2 +- YatteeTests/SettingsTests.swift | 14 +++ 25 files changed, 188 insertions(+), 21 deletions(-) diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index b9e2c140..982233c1 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -13,6 +13,7 @@ enum SettingsKey: String, CaseIterable { // General case theme case accentColor + case customAccentColor case showWatchedCheckmark // Playback diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 29165d27..16500a58 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) @@ -37,6 +38,23 @@ extension SettingsManager { } } + var customAccentColor: Color { + get { + let hex = _customAccentColor ?? string(for: .customAccentColor) ?? "" + return Color(hex: hex) ?? AccentColor.default.color + } + set { + let hex = newValue.toHexString() + _customAccentColor = hex + set(hex, for: .customAccentColor) + } + } + + /// The effective accent color, resolving `.custom` to the user-picked value. + var resolvedAccentColor: Color { + accentColor == .custom ? customAccentColor : accentColor.color + } + // MARK: - App Icon Settings var appIcon: AppIcon { diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index 8de0d154..917e011d 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -35,10 +35,20 @@ enum AccentColor: String, CaseIterable, Codable { case blue case purple case indigo + case custom + /// Fixed swatches shown in the settings grid; `.custom` is rendered + /// separately as a color picker, and `.indigo` is retired from the grid + /// but still resolves for users who selected it before it was removed. + static var presets: [AccentColor] { + allCases.filter { $0 != .custom && $0 != .indigo } + } + + /// The color for `.custom` lives in settings storage — resolve through + /// `SettingsManager.resolvedAccentColor` instead of this property. var color: Color { switch self { - case .default: return .blue // System default accent color + case .default, .custom: return .blue // System default accent color case .red: return .red case .pink: return .pink case .orange: return .orange diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 14ac7a6c..f17b8db9 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -28,6 +28,7 @@ final class SettingsManager { // Theme var _theme: AppTheme? var _accentColor: AccentColor? + var _customAccentColor: String? var _showWatchedCheckmark: Bool? // Playback @@ -417,6 +418,7 @@ final class SettingsManager { func clearCache() { _theme = nil _accentColor = nil + _customAccentColor = nil _showWatchedCheckmark = nil _preferredQuality = nil _cellularQuality = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 6b68ae7a..7727472c 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -10353,6 +10353,16 @@ } } }, + "settings.appearance.accentColor.custom" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom" + } + } + } + }, "settings.appearance.accentColor.default" : { "localizations" : { "en" : { diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 17941e8e..669773b0 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -113,7 +113,7 @@ struct ChannelView: View { } private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } // Grid layout configuration diff --git a/Yattee/Views/Components/BookmarkCardView.swift b/Yattee/Views/Components/BookmarkCardView.swift index e42ae3ec..622a398b 100644 --- a/Yattee/Views/Components/BookmarkCardView.swift +++ b/Yattee/Views/Components/BookmarkCardView.swift @@ -18,7 +18,7 @@ struct BookmarkCardView: View { var isCompact: Bool = false private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var video: Video { diff --git a/Yattee/Views/Components/BookmarkRowView.swift b/Yattee/Views/Components/BookmarkRowView.swift index aaf99ec9..c76612e8 100644 --- a/Yattee/Views/Components/BookmarkRowView.swift +++ b/Yattee/Views/Components/BookmarkRowView.swift @@ -28,7 +28,7 @@ struct BookmarkRowView: View { var loadMoreVideos: LoadMoreVideosCallback? = nil private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var isBookmarkValid: Bool { diff --git a/Yattee/Views/Components/BookmarkTagsView.swift b/Yattee/Views/Components/BookmarkTagsView.swift index 129bb3e2..c98b3621 100644 --- a/Yattee/Views/Components/BookmarkTagsView.swift +++ b/Yattee/Views/Components/BookmarkTagsView.swift @@ -15,7 +15,7 @@ struct BookmarkTagsView: View { var maxVisible: Int = 3 private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var visibleTags: [String] { diff --git a/Yattee/Views/Components/CommentView.swift b/Yattee/Views/Components/CommentView.swift index 9da1b1ee..b72e0d68 100644 --- a/Yattee/Views/Components/CommentView.swift +++ b/Yattee/Views/Components/CommentView.swift @@ -24,7 +24,7 @@ struct CommentView: View { private var contentService: ContentService? { appEnvironment?.contentService } private var instancesManager: InstancesManager? { appEnvironment?.instancesManager } private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } - private var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor } + private var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } init(comment: Comment, videoID: String? = nil, source: ContentSource? = nil, isReply: Bool = false) { self.comment = comment diff --git a/Yattee/Views/Components/TagInputView.swift b/Yattee/Views/Components/TagInputView.swift index 6756decd..86e5cd7f 100644 --- a/Yattee/Views/Components/TagInputView.swift +++ b/Yattee/Views/Components/TagInputView.swift @@ -19,7 +19,7 @@ struct TagInputView: View { @FocusState private var textFieldFocused: Bool private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private let maxTags = 10 diff --git a/Yattee/Views/Downloads/DownloadsView.swift b/Yattee/Views/Downloads/DownloadsView.swift index 68dc4bee..cd0f392f 100644 --- a/Yattee/Views/Downloads/DownloadsView.swift +++ b/Yattee/Views/Downloads/DownloadsView.swift @@ -364,7 +364,7 @@ private struct CompletedDownloadsSectionContentView: View { let isGroupedMode: Bool private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var completedFiltered: [Download] { diff --git a/Yattee/Views/Home/HomeShortcutCardView.swift b/Yattee/Views/Home/HomeShortcutCardView.swift index e21d7a03..27b5cdc8 100644 --- a/Yattee/Views/Home/HomeShortcutCardView.swift +++ b/Yattee/Views/Home/HomeShortcutCardView.swift @@ -17,7 +17,7 @@ struct HomeShortcutCardView: View { #endif private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } let icon: String diff --git a/Yattee/Views/Home/HomeShortcutRowView.swift b/Yattee/Views/Home/HomeShortcutRowView.swift index c8367029..bc811f76 100644 --- a/Yattee/Views/Home/HomeShortcutRowView.swift +++ b/Yattee/Views/Home/HomeShortcutRowView.swift @@ -16,7 +16,7 @@ struct HomeShortcutRowView: View { var statusIndicator: StatusIndicator? private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } init( diff --git a/Yattee/Views/Home/HomeShortcutStyleView.swift b/Yattee/Views/Home/HomeShortcutStyleView.swift index 6e6ee156..c21b7505 100644 --- a/Yattee/Views/Home/HomeShortcutStyleView.swift +++ b/Yattee/Views/Home/HomeShortcutStyleView.swift @@ -19,7 +19,7 @@ struct HomeShortcutStyleView: View { /// The user's accent color, used by the Accent palette (uniform fill). private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } /// Called after any value changes so the owner persists immediately. The diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index e301d379..3d6750a9 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -34,7 +34,7 @@ struct HomeView: View { private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } #if !os(tvOS) @@ -408,7 +408,7 @@ struct HomeView: View { private func colorfulColor(atPosition position: Int) -> Color { let palette = settingsManager?.homeShortcutColorfulPalette ?? .accent let customHex = settingsManager?.homeShortcutCustomPaletteColors ?? [] - let accentColor = settingsManager?.accentColor.color ?? .accentColor + let accentColor = settingsManager?.resolvedAccentColor ?? .accentColor return HomeShortcutColorfulPalette.color(forPosition: position, palette: palette, customHex: customHex, accentColor: accentColor) } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index bdc42ace..1b55d3ab 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -102,7 +102,7 @@ struct ExpandedPlayerSheet: View { var playerState: PlayerState? { playerService?.state } var downloadManager: DownloadManager? { appEnvironment?.downloadManager } var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } - var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor } + var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } #if os(iOS) var inAppOrientationLock: Bool { appEnvironment?.settingsManager.inAppOrientationLock ?? false } diff --git a/Yattee/Views/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index 88c75828..c1b071c6 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -47,7 +47,7 @@ struct FloatingDetailsPanel: View { @State private var isDragHandleActive: Bool = false private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } - private var accentColor: Color { settingsManager?.accentColor.color ?? .accentColor } + private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor } private var playerService: PlayerService? { appEnvironment?.playerService } private var playerState: PlayerState? { playerService?.state } diff --git a/Yattee/Views/Player/PanelPinButton.swift b/Yattee/Views/Player/PanelPinButton.swift index 2531bdc3..12ea81c5 100644 --- a/Yattee/Views/Player/PanelPinButton.swift +++ b/Yattee/Views/Player/PanelPinButton.swift @@ -25,7 +25,7 @@ struct PanelPinButton: View { private static let buttonSize: CGFloat = 36 private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } var body: some View { diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index d5867aea..bce7caf3 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -31,7 +31,7 @@ struct PortraitDetailsPanel: View { @State private var showPlaylistSheet: Bool = false private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } - private var accentColor: Color { settingsManager?.accentColor.color ?? .accentColor } + private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor } private var playerService: PlayerService? { appEnvironment?.playerService } private var playerState: PlayerState? { playerService?.state } private var isPanelDragging: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false } diff --git a/Yattee/Views/Settings/AppearanceSettingsView.swift b/Yattee/Views/Settings/AppearanceSettingsView.swift index dbf5e3c1..f2373248 100644 --- a/Yattee/Views/Settings/AppearanceSettingsView.swift +++ b/Yattee/Views/Settings/AppearanceSettingsView.swift @@ -6,6 +6,9 @@ // import SwiftUI +#if os(macOS) +import AppKit +#endif struct AppearanceSettingsView: View { @Environment(\.appEnvironment) private var appEnvironment @@ -146,13 +149,17 @@ private struct AccentColorSection: View { var body: some View { SettingsFormSection("settings.appearance.accentColor.header") { LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))], spacing: 16) { - ForEach(AccentColor.allCases, id: \.self) { accentColor in + ForEach(AccentColor.presets, id: \.self) { accentColor in AccentColorButton( accentColor: accentColor, isSelected: settings.accentColor == accentColor, onSelect: { settings.accentColor = accentColor } ) } + + #if !os(tvOS) + CustomAccentColorButton(settings: settings) + #endif } .padding(.vertical, 8) } @@ -222,6 +229,110 @@ private struct AccentColorButton: View { } } +// MARK: - Custom Accent Color Button + +#if !os(tvOS) +private struct CustomAccentColorButton: View { + @Bindable var settings: SettingsManager + + private var isSelected: Bool { settings.accentColor == .custom } + + #if os(macOS) + var body: some View { + Button { + settings.accentColor = .custom + ColorPanelBridge.shared.open(with: NSColor(settings.customAccentColor)) { nsColor in + settings.customAccentColor = Color(nsColor: nsColor) + settings.accentColor = .custom + } + } label: { + ZStack { + Circle() + .strokeBorder( + AngularGradient( + colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red], + center: .center + ), + lineWidth: 4 + ) + .frame(width: 40, height: 40) + + Circle() + .fill(settings.customAccentColor) + .frame(width: 28, height: 28) + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "settings.appearance.accentColor.custom")) + } + #else + private var pickedColor: Binding { + Binding( + get: { settings.customAccentColor }, + set: { newColor in + settings.customAccentColor = newColor + settings.accentColor = .custom + } + ) + } + + var body: some View { + ZStack { + Circle() + .fill(settings.customAccentColor) + .frame(width: 40, height: 40) + .opacity(isSelected ? 1 : 0) + + ColorPicker( + String(localized: "settings.appearance.accentColor.custom"), + selection: pickedColor, + supportsOpacity: false + ) + .labelsHidden() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + .allowsHitTesting(false) + } + } + .accessibilityLabel(String(localized: "settings.appearance.accentColor.custom")) + } + #endif +} + +#if os(macOS) +/// Routes NSColorPanel target/action callbacks to the settings binding. +/// NSColorPanel keeps only a weak target, so this must outlive the view. +@MainActor +private final class ColorPanelBridge: NSObject { + static let shared = ColorPanelBridge() + private var onChange: ((NSColor) -> Void)? + + func open(with color: NSColor, onChange: @escaping (NSColor) -> Void) { + self.onChange = onChange + let panel = NSColorPanel.shared + panel.showsAlpha = false + panel.color = color + panel.setTarget(self) + panel.setAction(#selector(colorChanged(_:))) + panel.makeKeyAndOrderFront(nil) + } + + @objc private func colorChanged(_ sender: NSColorPanel) { + onChange?(sender.color) + } +} +#endif +#endif + // MARK: - Theme Display Names extension AppTheme { @@ -251,6 +362,7 @@ extension AccentColor { case .blue: return String(localized: "settings.appearance.accentColor.blue") case .purple: return String(localized: "settings.appearance.accentColor.purple") case .indigo: return String(localized: "settings.appearance.accentColor.indigo") + case .custom: return String(localized: "settings.appearance.accentColor.custom") } } } diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index ddc4a42e..bdb084b6 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -171,7 +171,7 @@ struct SubscriptionsView: View { private var dataManager: DataManager? { appEnvironment?.dataManager } private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService } - private var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor } + private var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var yatteeServer: Instance? { appEnvironment?.instancesManager.enabledYatteeServerInstances.first } diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index b9d52a4f..985bba6f 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -134,7 +134,7 @@ struct VideoInfoView: View { } private var accentColor: Color { - appEnvironment?.settingsManager.accentColor.color ?? .accentColor + appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } private var dataManager: DataManager? { appEnvironment?.dataManager } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index ff0fcdf9..ee8e909a 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -71,7 +71,7 @@ struct YatteeApp: App { .appEnvironment(appEnvironment) #if !os(tvOS) .preferredColorScheme(appEnvironment.settingsManager.theme.colorScheme) - .tint(appEnvironment.settingsManager.accentColor.color) + .tint(appEnvironment.settingsManager.resolvedAccentColor) #endif #if os(macOS) .frame(minWidth: 800, minHeight: 500) diff --git a/YatteeTests/SettingsTests.swift b/YatteeTests/SettingsTests.swift index 67955836..6cc6f2f4 100644 --- a/YatteeTests/SettingsTests.swift +++ b/YatteeTests/SettingsTests.swift @@ -65,6 +65,20 @@ struct AccentColorTests { #expect(color == decoded) } } + + @Test("Presets exclude custom and retired indigo") + func presetsExcludeHiddenCases() { + #expect(!AccentColor.presets.contains(.custom)) + #expect(!AccentColor.presets.contains(.indigo)) + #expect(AccentColor.presets.count == AccentColor.allCases.count - 2) + } + + @Test("Retired indigo still decodes and resolves") + func retiredIndigoStillWorks() throws { + let decoded = try JSONDecoder().decode(AccentColor.self, from: Data("\"indigo\"".utf8)) + #expect(decoded == .indigo) + #expect(AccentColor(rawValue: "indigo") == .indigo) + } } // MARK: - VideoQuality Tests