Skip to content

Commit 8f03d03

Browse files
committed
accent color
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 370d325 commit 8f03d03

File tree

6 files changed

+256
-0
lines changed

6 files changed

+256
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import AppKit
2+
import CoreGraphics
3+
import SwiftUI
4+
5+
/// Extracts dominant colors from images for UI accent backgrounds.
6+
enum ColorExtractor {
7+
/// Represents a weighted color sample for averaging.
8+
private struct WeightedColor {
9+
let red: CGFloat
10+
let green: CGFloat
11+
let blue: CGFloat
12+
let weight: CGFloat
13+
}
14+
15+
/// Represents extracted color palette from an image.
16+
struct ColorPalette: Equatable, Sendable {
17+
let primary: Color
18+
let secondary: Color
19+
20+
/// Default dark palette when no image is available.
21+
static let `default` = ColorPalette(
22+
primary: Color(nsColor: NSColor(white: 0.1, alpha: 1)),
23+
secondary: Color(nsColor: NSColor(white: 0.05, alpha: 1))
24+
)
25+
}
26+
27+
/// Extracts a color palette from an NSImage.
28+
/// Uses k-means clustering on downsampled image for performance.
29+
/// - Parameter image: The source image.
30+
/// - Returns: A ColorPalette with primary and secondary colors.
31+
static func extractPalette(from image: NSImage) -> ColorPalette {
32+
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
33+
return .default
34+
}
35+
36+
// Downsample for performance (8x8 is enough for dominant color)
37+
let sampleSize = 8
38+
guard let context = createBitmapContext(width: sampleSize, height: sampleSize) else {
39+
return .default
40+
}
41+
42+
context.interpolationQuality = .medium
43+
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: sampleSize, height: sampleSize))
44+
45+
guard let data = context.data else {
46+
return .default
47+
}
48+
49+
let pointer = data.bindMemory(to: UInt8.self, capacity: sampleSize * sampleSize * 4)
50+
var colors: [WeightedColor] = []
51+
52+
// Sample pixels
53+
for yCoord in 0..<sampleSize {
54+
for xCoord in 0..<sampleSize {
55+
let offset = (yCoord * sampleSize + xCoord) * 4
56+
let red = CGFloat(pointer[offset]) / 255.0
57+
let green = CGFloat(pointer[offset + 1]) / 255.0
58+
let blue = CGFloat(pointer[offset + 2]) / 255.0
59+
60+
// Weight by saturation and avoid near-black/white pixels
61+
let maxC = max(red, green, blue)
62+
let minC = min(red, green, blue)
63+
let saturation = maxC > 0 ? (maxC - minC) / maxC : 0
64+
let brightness = maxC
65+
66+
// Skip very dark or very light pixels
67+
if brightness > 0.1 && brightness < 0.95 {
68+
let weight = saturation * 0.7 + 0.3
69+
colors.append(WeightedColor(red: red, green: green, blue: blue, weight: weight))
70+
}
71+
}
72+
}
73+
74+
// Find dominant color using weighted average
75+
guard !colors.isEmpty else {
76+
return .default
77+
}
78+
79+
let totalWeight = colors.reduce(0) { $0 + $1.weight }
80+
guard totalWeight > 0 else {
81+
return .default
82+
}
83+
84+
let avgR = colors.reduce(0) { $0 + $1.red * $1.weight } / totalWeight
85+
let avgG = colors.reduce(0) { $0 + $1.green * $1.weight } / totalWeight
86+
let avgB = colors.reduce(0) { $0 + $1.blue * $1.weight } / totalWeight
87+
88+
// Create primary color (saturated and darker for background)
89+
let primary = adjustColorForBackground(r: avgR, g: avgG, b: avgB, darken: 0.4)
90+
91+
// Create secondary color (even darker for gradient end)
92+
let secondary = adjustColorForBackground(r: avgR, g: avgG, b: avgB, darken: 0.7)
93+
94+
return ColorPalette(
95+
primary: Color(nsColor: primary),
96+
secondary: Color(nsColor: secondary)
97+
)
98+
}
99+
100+
/// Extracts palette from image data off the main actor.
101+
/// - Parameter data: Raw image data.
102+
/// - Returns: Extracted color palette.
103+
@MainActor
104+
static func extractPalette(from data: Data) async -> ColorPalette {
105+
await Task.detached(priority: .userInitiated) {
106+
guard let image = NSImage(data: data) else {
107+
return ColorPalette.default
108+
}
109+
return extractPalette(from: image)
110+
}.value
111+
}
112+
113+
// MARK: - Private Helpers
114+
115+
private static func createBitmapContext(width: Int, height: Int) -> CGContext? {
116+
CGContext(
117+
data: nil,
118+
width: width,
119+
height: height,
120+
bitsPerComponent: 8,
121+
bytesPerRow: width * 4,
122+
space: CGColorSpaceCreateDeviceRGB(),
123+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
124+
)
125+
}
126+
127+
private static func adjustColorForBackground(
128+
r: CGFloat,
129+
g: CGFloat,
130+
b: CGFloat,
131+
darken: CGFloat
132+
) -> NSColor {
133+
// Convert to HSB for easier manipulation
134+
let nsColor = NSColor(red: r, green: g, blue: b, alpha: 1.0)
135+
var hue: CGFloat = 0
136+
var saturation: CGFloat = 0
137+
var brightness: CGFloat = 0
138+
var alpha: CGFloat = 0
139+
140+
nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
141+
142+
// Increase saturation slightly and darken significantly
143+
let adjustedSaturation = min(saturation * 1.2, 1.0)
144+
let adjustedBrightness = brightness * (1 - darken)
145+
146+
return NSColor(
147+
hue: hue,
148+
saturation: adjustedSaturation,
149+
brightness: max(adjustedBrightness, 0.05),
150+
alpha: 1.0
151+
)
152+
}
153+
}

Kaset.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
E50000010000000000000403 /* PlaylistParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000403 /* PlaylistParserTests.swift */; };
8888
E50000010000000000000500 /* ErrorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000500 /* ErrorPresenter.swift */; };
8989
E50000010000000000000501 /* ErrorPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000501 /* ErrorPresenterTests.swift */; };
90+
E50000010000000000000076 /* ColorExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000076 /* ColorExtractor.swift */; };
91+
E50000010000000000000093 /* AccentBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000020000000000000093 /* AccentBackground.swift */; };
9092
/* End PBXBuildFile section */
9193

9294
/* Begin PBXContainerItemProxy section */
@@ -185,6 +187,8 @@
185187
E50000020000000000000403 /* PlaylistParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistParserTests.swift; sourceTree = "<group>"; };
186188
E50000020000000000000500 /* ErrorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPresenter.swift; sourceTree = "<group>"; };
187189
E50000020000000000000501 /* ErrorPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPresenterTests.swift; sourceTree = "<group>"; };
190+
E50000020000000000000076 /* ColorExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtractor.swift; sourceTree = "<group>"; };
191+
E50000020000000000000093 /* AccentBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentBackground.swift; sourceTree = "<group>"; };
188192
F525663B77A8906E1792B4EC /* ArtistDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ArtistDetailViewModel.swift; sourceTree = "<group>"; };
189193
/* End PBXFileReference section */
190194

@@ -370,6 +374,7 @@
370374
E50000020000000000000071 /* Extensions.swift */,
371375
E50000020000000000000074 /* RetryPolicy.swift */,
372376
E50000020000000000000075 /* ImageCache.swift */,
377+
E50000020000000000000076 /* ColorExtractor.swift */,
373378
);
374379
path = Utilities;
375380
sourceTree = "<group>";
@@ -392,6 +397,7 @@
392397
E50000020000000000000089 /* CachedAsyncImage.swift */,
393398
B5B735BB57DD2ACF165D683F /* MiniPlayerWebView.swift */,
394399
D2E3F4A5B6C7D8E9F0A1B2C3 /* MiniPlayerViews.swift */,
400+
E50000020000000000000093 /* AccentBackground.swift */,
395401
);
396402
path = macOS;
397403
sourceTree = "<group>";
@@ -590,6 +596,8 @@
590596
E50000010000000000000303 /* PlaylistParser.swift in Sources */,
591597
E50000010000000000000304 /* ArtistParser.swift in Sources */,
592598
E50000010000000000000500 /* ErrorPresenter.swift in Sources */,
599+
E50000010000000000000076 /* ColorExtractor.swift in Sources */,
600+
E50000010000000000000093 /* AccentBackground.swift in Sources */,
593601
);
594602
runOnlyForDeploymentPostprocessing = 0;
595603
};

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,6 @@ brew install --cask kaset --no-quarantine
6767
## Contributing
6868
6969
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture, and coding guidelines.
70+
71+
## Disclaimer
72+
Kaset is an unofficial application and not affiliated with YouTube or Google Inc. in any way. "YouTube", "YouTube Music" and the "YouTube Logo" are registered trademarks of Google Inc.

Views/macOS/AccentBackground.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import SwiftUI
2+
3+
/// A background view that displays a gradient based on colors extracted from an image.
4+
/// Creates an effect similar to Apple Music/YouTube Music album backgrounds.
5+
@available(macOS 26.0, *)
6+
struct AccentBackground: View {
7+
let imageURL: URL?
8+
@State private var palette: ColorExtractor.ColorPalette = .default
9+
@State private var isLoaded = false
10+
11+
var body: some View {
12+
ZStack {
13+
// Base gradient from extracted colors
14+
LinearGradient(
15+
colors: [palette.primary, palette.secondary, Color.black.opacity(0.95)],
16+
startPoint: .top,
17+
endPoint: .bottom
18+
)
19+
20+
// Subtle radial overlay for depth
21+
RadialGradient(
22+
colors: [
23+
palette.primary.opacity(0.3),
24+
Color.clear
25+
],
26+
center: .topLeading,
27+
startRadius: 0,
28+
endRadius: 500
29+
)
30+
}
31+
.animation(.easeInOut(duration: 0.5), value: isLoaded)
32+
.task(id: imageURL) {
33+
await loadPalette()
34+
}
35+
}
36+
37+
private func loadPalette() async {
38+
guard let url = imageURL else {
39+
palette = .default
40+
isLoaded = true
41+
return
42+
}
43+
44+
// Fetch image data
45+
do {
46+
let (data, _) = try await URLSession.shared.data(from: url)
47+
let extracted = await ColorExtractor.extractPalette(from: data)
48+
palette = extracted
49+
isLoaded = true
50+
} catch {
51+
DiagnosticsLogger.ui.debug("Failed to extract accent colors: \(error.localizedDescription)")
52+
palette = .default
53+
isLoaded = true
54+
}
55+
}
56+
}
57+
58+
/// View modifier to apply accent background based on album art.
59+
@available(macOS 26.0, *)
60+
struct AccentBackgroundModifier: ViewModifier {
61+
let imageURL: URL?
62+
63+
func body(content: Content) -> some View {
64+
content
65+
.background {
66+
AccentBackground(imageURL: imageURL)
67+
.ignoresSafeArea()
68+
}
69+
}
70+
}
71+
72+
@available(macOS 26.0, *)
73+
extension View {
74+
/// Applies an accent color background gradient extracted from the given image URL.
75+
/// - Parameter imageURL: The URL of the image to extract colors from.
76+
/// - Returns: A view with the accent background applied.
77+
func accentBackground(from imageURL: URL?) -> some View {
78+
modifier(AccentBackgroundModifier(imageURL: imageURL))
79+
}
80+
}
81+
82+
#Preview {
83+
VStack {
84+
Text("Accent Background Preview")
85+
.font(.largeTitle)
86+
.foregroundStyle(.white)
87+
}
88+
.frame(width: 400, height: 600)
89+
.accentBackground(from: nil)
90+
}

Views/macOS/ArtistDetailView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ struct ArtistDetailView: View {
2222
errorView(message: message)
2323
}
2424
}
25+
.accentBackground(from: viewModel.artistDetail?.thumbnailURL?.highQualityThumbnailURL)
2526
.navigationTitle(artist.name)
2627
.safeAreaInset(edge: .bottom, spacing: 0) {
2728
PlayerBar()

Views/macOS/PlaylistDetailView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ struct PlaylistDetailView: View {
3535
errorView(message: message)
3636
}
3737
}
38+
.accentBackground(from: viewModel.playlistDetail?.thumbnailURL?.highQualityThumbnailURL)
3839
.navigationTitle(playlist.title)
3940
.safeAreaInset(edge: .bottom, spacing: 0) {
4041
PlayerBar()

0 commit comments

Comments
 (0)