Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 15 additions & 64 deletions Shfl/Resources/Shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
109 changes: 109 additions & 0 deletions Shfl/Theme/ColorBlending.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
87 changes: 87 additions & 0 deletions Shfl/Theme/TintedThemeProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
40 changes: 14 additions & 26 deletions Shfl/Views/Components/BrushedMetalBackground.swift
Original file line number Diff line number Diff line change
@@ -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)
}
44 changes: 26 additions & 18 deletions Shfl/Views/Components/ClassicPlayerLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading