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