From f54bd55a81a6620c35c8827edfbbd3422e1c4ea7 Mon Sep 17 00:00:00 2001 From: Joshua Hughes Date: Sat, 31 Jan 2026 21:31:49 -0500 Subject: [PATCH] feat: album-aware theme tinting and full-screen background - Add ColorBlending utilities for HSB color manipulation - Add TintedThemeProvider to blend album colors with theme hues - Replace rectangular panel with full-screen tinted background - Simplify brushed metal shader to subtle grain texture - Song info and click wheel now float directly on background The theme swipe gesture still works - each theme now "tints" the album's dominant color rather than replacing it entirely. Co-Authored-By: Claude Opus 4.5 --- Shfl/Resources/Shaders.metal | 79 +++---------- Shfl/Theme/ColorBlending.swift | 109 ++++++++++++++++++ Shfl/Theme/TintedThemeProvider.swift | 87 ++++++++++++++ .../Components/BrushedMetalBackground.swift | 40 +++---- .../Components/ClassicPlayerLayout.swift | 44 ++++--- Shfl/Views/PlayerView.swift | 27 +++-- 6 files changed, 271 insertions(+), 115 deletions(-) create mode 100644 Shfl/Theme/ColorBlending.swift create mode 100644 Shfl/Theme/TintedThemeProvider.swift diff --git a/Shfl/Resources/Shaders.metal b/Shfl/Resources/Shaders.metal index 316b476..84c440a 100644 --- a/Shfl/Resources/Shaders.metal +++ b/Shfl/Resources/Shaders.metal @@ -30,69 +30,20 @@ static float shfl_noise(float2 st) { float2 highlightOffset, float intensity ) { - // Convert to polar coordinates relative to the view center - float2 delta = position - center; - float radius = length(delta); - float angle = atan2(delta.y, delta.x); - - // --- TEXTURE GENERATION --- - - // 1. Base Grain (The main "spun" look) - float baseGrain = shfl_noise(float2(radius * 400.0, angle * 2.0)); - - // 2. Micro-scratches (Fine detail) - float scratches = shfl_noise(float2(radius * 1200.0 + shfl_noise(float2(angle * 50.0)) * 20.0, angle * 20.0)); - - // 3. Radial variation (Subtle rings) - float rings = shfl_noise(float2(radius * 50.0, 0.0)); - - // 4. Low-frequency waviness (New layer for "realism" and imperfections) - // Helps avoid the perfect computer-generated look. - float waviness = shfl_noise(float2(radius * 10.0, angle * 4.0)); - - // Compose the height map (0.0 to 1.0) - // Increased weight of scratches and waviness for more "bite" - float height = (baseGrain * 0.4 + scratches * 0.4 + rings * 0.1 + waviness * 0.1); - - // --- LIGHTING --- - - // Calculate Normal from height map - // Tangent follows the brush direction (angular) - float2 tangent = float2(-sin(angle), cos(angle)); - // Bitangent points outwards (radial) - float2 bitangent = float2(cos(angle), sin(angle)); - - // Perturb normal - increased perturbation for more "crunchy" metal feel - float normalPerturb = (height - 0.5) * 1.5; - float3 surfaceNormal = normalize(float3(bitangent * normalPerturb * 0.5, 1.0)); - - // Light Source - // Reduced tilt sensitivity slightly to keep glare more centered/controlled - float3 lightDir = normalize(float3(highlightOffset.x * 0.005, highlightOffset.y * 0.005, 1.0)); - - // Specular Reflection (Blinn-Phong) - float3 viewDir = float3(0.0, 0.0, 1.0); - float3 halfwayDir = normalize(lightDir + viewDir); - float specAngle = max(dot(surfaceNormal, halfwayDir), 0.0); - - // Anisotropic highlight - // BROADER falloff (lower exponent) for a softer, less "laser-like" glare. - // This helps avoid the "cut off" feel by spreading the light more. - float specular = pow(specAngle, 8.0); - - // --- COMPOSITION --- - - // Deepen the ambient occlusion in grooves - float occlusion = 0.7 + 0.3 * height; - - half3 baseColor = color.rgb * occlusion; - - // Warm, soft highlight - half3 highlightColor = half3(1.0, 0.98, 0.95); - - // Final mix - // Reduced intensity multiplier to fix "insane glare" - half3 finalColor = baseColor + highlightColor * specular * intensity * 0.25; - + // Subtle grain texture - no directional pattern + + // Fine grain at different scales for natural look + float fineGrain = shfl_noise(position * 2.0); + float mediumGrain = shfl_noise(position * 0.5); + + // Combine grains - mostly fine detail with subtle low-frequency variation + float grain = fineGrain * 0.7 + mediumGrain * 0.3; + + // Very subtle variation: 0.95 to 1.05 range (±5%) + float variation = 0.95 + grain * 0.1 * intensity; + + // Apply subtle grain to color + half3 finalColor = color.rgb * variation; + return half4(finalColor, color.a); } diff --git a/Shfl/Theme/ColorBlending.swift b/Shfl/Theme/ColorBlending.swift new file mode 100644 index 0000000..147e34f --- /dev/null +++ b/Shfl/Theme/ColorBlending.swift @@ -0,0 +1,109 @@ +import SwiftUI +import UIKit + +/// HSB color math utilities for theme tinting +enum ColorBlending { + struct HSB { + var hue: CGFloat + var saturation: CGFloat + var brightness: CGFloat + + func toColor() -> Color { + Color(hue: hue, saturation: saturation, brightness: brightness) + } + } + + /// Extract HSB components from a SwiftUI Color + static func extractHSB(from color: Color) -> HSB { + let uiColor = UIColor(color) + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + uiColor.getHue(&h, saturation: &s, brightness: &b, alpha: nil) + return HSB(hue: h, saturation: s, brightness: b) + } + + /// Interpolate between two hues, handling circular wraparound (0.0 and 1.0 are both red) + static func lerpHue(_ h1: CGFloat, _ h2: CGFloat, _ t: CGFloat) -> CGFloat { + var delta = h2 - h1 + + // Take the shortest path around the color wheel + if delta > 0.5 { delta -= 1.0 } + if delta < -0.5 { delta += 1.0 } + + var result = h1 + delta * t + + // Normalize to [0, 1) + if result < 0 { result += 1.0 } + if result >= 1.0 { result -= 1.0 } + + return result + } + + /// Calculate relative luminance for contrast decisions (WCAG formula) + static func relativeLuminance(of color: Color) -> CGFloat { + let uiColor = UIColor(color) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: nil) + + // sRGB relative luminance + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + + /// Blend an album color with a theme color, shifting the album's hue toward the theme + /// + /// - Parameters: + /// - albumColor: The dominant color extracted from album artwork + /// - themeColor: The reference color from the selected theme + /// - hueFactor: How much to shift toward theme hue (0 = album only, 1 = theme only). Default 0.35 + /// - minSaturation: Minimum saturation to ensure vibrancy. Default 0.3 + /// - maxSaturation: Maximum saturation to prevent harshness. Default 0.85 + /// - minBrightness: Minimum brightness for readability. Default 0.4 + /// - maxBrightness: Maximum brightness to avoid washed out. Default 0.8 + static func blend( + albumColor: Color, + themeColor: Color, + hueFactor: CGFloat = 0.35, + minSaturation: CGFloat = 0.3, + maxSaturation: CGFloat = 0.85, + minBrightness: CGFloat = 0.4, + maxBrightness: CGFloat = 0.8 + ) -> Color { + let album = extractHSB(from: albumColor) + let theme = extractHSB(from: themeColor) + + let blendedHue = lerpHue(album.hue, theme.hue, hueFactor) + + // For very desaturated album colors, lean more toward theme saturation + var blendedSaturation = album.saturation + if album.saturation < 0.15 { + blendedSaturation = album.saturation + (theme.saturation - album.saturation) * 0.5 + } + blendedSaturation = min(max(blendedSaturation, minSaturation), maxSaturation) + + let blendedBrightness = min(max(album.brightness, minBrightness), maxBrightness) + + return Color(hue: blendedHue, saturation: blendedSaturation, brightness: blendedBrightness) + } + + /// Create a darker variant for gradient bottom (reduces brightness by a percentage) + static func darken(_ color: Color, by amount: CGFloat = 0.12) -> Color { + let hsb = extractHSB(from: color) + let darkerBrightness = max(hsb.brightness - amount, 0.2) + return Color(hue: hsb.hue, saturation: hsb.saturation, brightness: darkerBrightness) + } + + /// Determine appropriate wheel and text styles based on color luminance + static func determineStyles(for color: Color) -> (wheel: ShuffleTheme.WheelStyle, text: ShuffleTheme.TextStyle, iconColor: Color) { + let luminance = relativeLuminance(of: color) + + // Threshold around 0.5 - lighter colors need dark text/wheel for contrast + if luminance > 0.45 { + return (.dark, .dark, .black) + } else { + return (.light, .light, .white) + } + } +} diff --git a/Shfl/Theme/TintedThemeProvider.swift b/Shfl/Theme/TintedThemeProvider.swift new file mode 100644 index 0000000..2714f9d --- /dev/null +++ b/Shfl/Theme/TintedThemeProvider.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Computes album-aware theme colors by blending album art colors with the selected theme +@Observable +@MainActor +final class TintedThemeProvider { + /// The computed theme with blended colors, ready for environment injection + private(set) var computedTheme: ShuffleTheme + + /// The base theme before tinting (for reference) + private var baseTheme: ShuffleTheme + + init() { + // Start with a default silver theme; will be updated immediately on appear + let defaultTheme = ShuffleTheme( + id: "silver", + name: "Silver", + bodyGradientTop: Color(red: 0.58, green: 0.58, blue: 0.60), + bodyGradientBottom: Color(red: 0.48, green: 0.48, blue: 0.50), + wheelStyle: .dark, + textStyle: .dark, + centerButtonIconColor: .black, + brushedMetalIntensity: 1.0, + motionEnabled: true, + motionSensitivity: 1.0 + ) + self.baseTheme = defaultTheme + self.computedTheme = defaultTheme + } + + /// Update the computed theme by blending album color with the current theme + /// + /// - Parameters: + /// - albumColor: The dominant color from album artwork, or nil to use pure theme + /// - theme: The base theme to tint toward + func update(albumColor: Color?, theme: ShuffleTheme) { + baseTheme = theme + + guard let albumColor else { + // No album color - use pure theme + withAnimation(.easeInOut(duration: 0.5)) { + computedTheme = theme + } + return + } + + // Blend album color with theme + let blendedTop = ColorBlending.blend( + albumColor: albumColor, + themeColor: theme.bodyGradientTop, + hueFactor: hueFactor(for: theme), + maxSaturation: maxSaturation(for: theme) + ) + + let blendedBottom = ColorBlending.darken(blendedTop, by: 0.12) + + // Determine wheel/text styles based on blended color luminance + let (wheelStyle, textStyle, iconColor) = ColorBlending.determineStyles(for: blendedTop) + + withAnimation(.easeInOut(duration: 0.5)) { + computedTheme = ShuffleTheme( + id: theme.id, + name: theme.name, + bodyGradientTop: blendedTop, + bodyGradientBottom: blendedBottom, + wheelStyle: wheelStyle, + textStyle: textStyle, + centerButtonIconColor: iconColor, + brushedMetalIntensity: theme.brushedMetalIntensity, + motionEnabled: theme.motionEnabled, + motionSensitivity: theme.motionSensitivity + ) + } + } + + // MARK: - Theme-specific adjustments + + /// Silver theme blends less aggressively to preserve its metallic character + private func hueFactor(for theme: ShuffleTheme) -> CGFloat { + theme.id == "silver" ? 0.25 : 0.35 + } + + /// Silver theme caps saturation lower to keep the aluminum feel + private func maxSaturation(for theme: ShuffleTheme) -> CGFloat { + theme.id == "silver" ? 0.5 : 0.85 + } +} diff --git a/Shfl/Views/Components/BrushedMetalBackground.swift b/Shfl/Views/Components/BrushedMetalBackground.swift index 6e5152d..0c3eb3d 100644 --- a/Shfl/Views/Components/BrushedMetalBackground.swift +++ b/Shfl/Views/Components/BrushedMetalBackground.swift @@ -1,45 +1,33 @@ import SwiftUI +/// Full-screen brushed metal background using the tinted theme color struct BrushedMetalBackground: View { - let baseColor: Color - let intensity: CGFloat - let highlightOffset: CGPoint - let motionEnabled: Bool - let highlightColor: Color + @Environment(\.shuffleTheme) private var theme - init( - baseColor: Color, - intensity: CGFloat = 0.5, - highlightOffset: CGPoint = .zero, - motionEnabled: Bool = true, - highlightColor: Color = .white - ) { - self.baseColor = baseColor - self.intensity = intensity - self.highlightOffset = motionEnabled ? highlightOffset : .zero - self.motionEnabled = motionEnabled - self.highlightColor = highlightColor - } + let highlightOffset: CGPoint var body: some View { GeometryReader { geometry in - let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2) - Rectangle() - .fill(baseColor) + .fill(theme.bodyGradientTop) .colorEffect( ShaderLibrary.shfl_brushedMetal( - .float2(center), + .float2(geometry.size.width / 2, geometry.size.height / 2), .float2(highlightOffset), - .float(intensity) + .float(theme.brushedMetalIntensity) ) ) + .ignoresSafeArea() } } +} - +#Preview("Silver") { + BrushedMetalBackground(highlightOffset: .zero) + .environment(\.shuffleTheme, .silver) } -#Preview { - BrushedMetalBackground(baseColor: Color(red: 0.75, green: 0.75, blue: 0.75)) +#Preview("Green") { + BrushedMetalBackground(highlightOffset: .zero) + .environment(\.shuffleTheme, .green) } diff --git a/Shfl/Views/Components/ClassicPlayerLayout.swift b/Shfl/Views/Components/ClassicPlayerLayout.swift index 1d39941..518608c 100644 --- a/Shfl/Views/Components/ClassicPlayerLayout.swift +++ b/Shfl/Views/Components/ClassicPlayerLayout.swift @@ -28,29 +28,37 @@ struct ClassicPlayerLayout: View { Spacer() - // Song info panel - brushed metal - ShuffleBodyView(highlightOffset: highlightOffset, height: 120) { - SongInfoDisplay( - playbackState: playbackState, - currentTime: currentTime, - duration: duration - ) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .padding(.horizontal, 20) + // Album art card - only show when there's a song + if let song = playbackState.currentSong { + AlbumArtCard(artworkURL: song.artworkURL, size: 320) + .padding(.bottom, 24) } - .padding(.horizontal, 20) - .padding(.bottom, 12) - // Controls panel - PlayerControlsPanel( + // Song info - floating directly on background + SongInfoDisplay( + playbackState: playbackState, + currentTime: currentTime, + duration: duration, + onSeek: actions.onSeek + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, 32) + .padding(.bottom, 20) + + // Click wheel - floating with shadow + ClickWheelView( isPlaying: playbackState.isPlaying, - isDisabled: isControlsDisabled, + onPlayPause: actions.onPlayPause, + onSkipForward: actions.onSkipForward, + onSkipBack: actions.onSkipBack, + onVolumeUp: { VolumeController.increaseVolume() }, + onVolumeDown: { VolumeController.decreaseVolume() }, highlightOffset: highlightOffset, - actions: actions + scale: 0.6 ) - .padding(.horizontal, 20) - .padding(.bottom, safeAreaInsets.bottom + 12) + .disabled(isControlsDisabled) + .opacity(isControlsDisabled ? 0.6 : 1.0) + .padding(.bottom, safeAreaInsets.bottom + 20) } .animation(.easeInOut(duration: 0.2), value: showError) } diff --git a/Shfl/Views/PlayerView.swift b/Shfl/Views/PlayerView.swift index 044711c..086f0c3 100644 --- a/Shfl/Views/PlayerView.swift +++ b/Shfl/Views/PlayerView.swift @@ -9,6 +9,7 @@ struct PlayerView: View { @Environment(\.motionManager) private var motionManager @State private var themeController = ThemeController() + @State private var tintProvider = TintedThemeProvider() @State private var progressState: PlayerProgressState? @State private var colorExtractor = AlbumArtColorExtractor() @State private var highlightOffset: CGPoint = .zero @@ -32,11 +33,7 @@ struct PlayerView: View { var body: some View { GeometryReader { geometry in ZStack { - AlbumArtBackground( - artworkURL: player.playbackState.currentSong?.artworkURL, - fallbackColor: themeController.currentTheme.bodyGradientTop, - blurRadius: 3 - ) + BrushedMetalBackground(highlightOffset: highlightOffset) ClassicPlayerLayout( playbackState: player.playbackState, @@ -58,13 +55,17 @@ struct PlayerView: View { .ignoresSafeArea() } .simultaneousGesture(themeController.makeSwipeGesture()) - .environment(\.shuffleTheme, themeController.currentTheme) + .environment(\.shuffleTheme, tintProvider.computedTheme) .onAppear { if progressState == nil { progressState = PlayerProgressState(musicService: musicService) } progressState?.startUpdating() motionManager?.start() + + // Initialize tint provider with current theme + tintProvider.update(albumColor: colorExtractor.extractedColor, theme: themeController.currentTheme) + if let song = player.playbackState.currentSong { colorExtractor.updateColor(for: song.id) } @@ -82,6 +83,12 @@ struct PlayerView: View { .onChange(of: motionManager?.roll) { _, _ in updateHighlightOffset() } + .onChange(of: colorExtractor.extractedColor) { _, newColor in + tintProvider.update(albumColor: newColor, theme: themeController.currentTheme) + } + .onChange(of: themeController.currentTheme) { _, newTheme in + tintProvider.update(albumColor: colorExtractor.extractedColor, theme: newTheme) + } } // MARK: - Actions @@ -93,10 +100,15 @@ struct PlayerView: View { onSkipBack: handleSkipBack, onManage: onManageTapped, onAdd: onAddTapped, - onSettings: onSettingsTapped + onSettings: onSettingsTapped, + onSeek: handleSeek ) } + private func handleSeek(_ time: TimeInterval) { + musicService.seek(to: time) + } + private func handlePlayPause() { Task { try? await player.togglePlayback() @@ -175,6 +187,7 @@ private final class PreviewMockMusicService: MusicService, @unchecked Sendable { func skipToNext() async throws {} func skipToPrevious() async throws {} func restartOrSkipToPrevious() async throws {} + func seek(to time: TimeInterval) {} } private let previewSong = Song(