mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 05:42:04 +00:00
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:
@@ -13,6 +13,7 @@ enum SettingsKey: String, CaseIterable {
|
||||
// General
|
||||
case theme
|
||||
case accentColor
|
||||
case customAccentColor
|
||||
case showWatchedCheckmark
|
||||
|
||||
// Playback
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10353,6 +10353,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.custom" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.appearance.accentColor.default" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@@ -113,7 +113,7 @@ struct ChannelView: View {
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
|
||||
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
|
||||
}
|
||||
|
||||
// Grid layout configuration
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user