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
This commit is contained in:
Arkadiusz Fal
2026-07-04 23:57:59 +02:00
parent f5b86effd3
commit b47888fe04
25 changed files with 188 additions and 21 deletions

View File

@@ -13,6 +13,7 @@ enum SettingsKey: String, CaseIterable {
// General
case theme
case accentColor
case customAccentColor
case showWatchedCheckmark
// Playback

View File

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

View File

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

View File

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

View File

@@ -10353,6 +10353,16 @@
}
}
},
"settings.appearance.accentColor.custom" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Custom"
}
}
}
},
"settings.appearance.accentColor.default" : {
"localizations" : {
"en" : {

View File

@@ -113,7 +113,7 @@ struct ChannelView: View {
}
private var accentColor: Color {
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
// Grid layout configuration

View File

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

View File

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

View File

@@ -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] {

View File

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

View File

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

View File

@@ -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] {

View File

@@ -17,7 +17,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
#endif
private var accentColor: Color {
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
let icon: String

View File

@@ -16,7 +16,7 @@ struct HomeShortcutRowView<StatusIndicator: View>: View {
var statusIndicator: StatusIndicator?
private var accentColor: Color {
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
init(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Color> {
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")
}
}
}

View File

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

View File

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

View File

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

View File

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