| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- import SwiftUI
- // MARK: - Rotary Knob
- /// A hardware-inspired rotary knob control. Drag vertically to adjust value.
- struct RotaryKnobView: View {
- @Binding var value: Float // 0...1
- var label: String = ""
- var size: CGFloat = 48
- var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
- @State private var isDragging = false
- @State private var dragStartValue: Float = 0
- // Knob rotates from -135deg to +135deg (270deg total range)
- private var rotationAngle: Double {
- Double(value) * 270 - 135
- }
- var body: some View {
- VStack(spacing: 4) {
- ZStack {
- // Outer ring — brushed metal
- Circle()
- .fill(
- AngularGradient(
- colors: [
- Color(white: 0.25), Color(white: 0.35),
- Color(white: 0.20), Color(white: 0.30),
- Color(white: 0.25),
- ],
- center: .center
- )
- )
- .frame(width: size, height: size)
- // Value arc
- Circle()
- .trim(from: 0, to: CGFloat(value) * 0.75)
- .rotation(.degrees(135))
- .stroke(
- accentColor,
- style: StrokeStyle(lineWidth: 3, lineCap: .round)
- )
- .frame(width: size + 6, height: size + 6)
- .shadow(color: accentColor.opacity(0.6), radius: 4)
- // Inner circle — darker center
- Circle()
- .fill(
- RadialGradient(
- colors: [Color(white: 0.18), Color(white: 0.12)],
- center: .center,
- startRadius: 0,
- endRadius: size * 0.35
- )
- )
- .frame(width: size * 0.7, height: size * 0.7)
- // Position indicator line
- Rectangle()
- .fill(accentColor)
- .frame(width: 2, height: size * 0.25)
- .offset(y: -size * 0.2)
- .rotationEffect(.degrees(rotationAngle))
- .shadow(color: accentColor.opacity(0.8), radius: 2)
- }
- .gesture(
- DragGesture(minimumDistance: 1)
- .onChanged { gesture in
- if !isDragging {
- isDragging = true
- dragStartValue = value
- }
- // Vertical drag: up = increase, down = decrease
- let delta = Float(-gesture.translation.height / 150)
- value = max(0, min(1, dragStartValue + delta))
- }
- .onEnded { _ in isDragging = false }
- )
- if !label.isEmpty {
- Text(label)
- .font(.system(size: 9, weight: .bold, design: .monospaced))
- .foregroundStyle(Color(white: 0.5))
- }
- }
- }
- }
- // MARK: - VU Meter
- /// Vertical LED-strip level meter. Green → Yellow → Red.
- struct VUMeterView: View {
- var level: Float // 0...1
- var segmentCount: Int = 12
- var width: CGFloat = 8
- var height: CGFloat = 60
- var body: some View {
- VStack(spacing: 1.5) {
- ForEach((0..<segmentCount).reversed(), id: \.self) { index in
- let threshold = Float(index) / Float(segmentCount)
- let isLit = level > threshold
- let color = segmentColor(index: index)
- RoundedRectangle(cornerRadius: 1)
- .fill(isLit ? color : color.opacity(0.15))
- .frame(width: width, height: max(2, height / CGFloat(segmentCount) - 1.5))
- .shadow(color: isLit ? color.opacity(0.5) : .clear, radius: 2)
- }
- }
- }
- private func segmentColor(index: Int) -> Color {
- let ratio = Float(index) / Float(segmentCount)
- if ratio >= 0.83 { return Color(red: 1.0, green: 0.1, blue: 0.1) } // Red
- if ratio >= 0.66 { return Color(red: 1.0, green: 0.8, blue: 0.0) } // Yellow
- return Color(red: 0.0, green: 0.85, blue: 0.4) // Green
- }
- }
- // MARK: - LED Display
- /// Seven-segment-style glowing text display for BPM, time, key.
- struct LEDDisplay: View {
- let text: String
- var fontSize: CGFloat = 16
- var color: Color = Color(red: 0, green: 0.83, blue: 1.0)
- var alignment: Alignment = .center
- var body: some View {
- Text(text)
- .font(.system(size: fontSize, weight: .bold, design: .monospaced))
- .foregroundStyle(color)
- .shadow(color: color.opacity(0.7), radius: 4)
- .shadow(color: color.opacity(0.3), radius: 8)
- .frame(maxWidth: .infinity, alignment: alignment)
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(
- RoundedRectangle(cornerRadius: 4)
- .fill(Color(white: 0.05))
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .stroke(Color(white: 0.15), lineWidth: 0.5)
- )
- )
- }
- }
- // MARK: - DJ Transport Button
- /// Hardware-inspired raised button for transport controls.
- struct DJTransportButton: View {
- let icon: String
- var size: ButtonSize = .regular
- var isActive: Bool = false
- var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
- let action: () -> Void
- enum ButtonSize {
- case small, regular, large
- var dimension: CGFloat {
- switch self {
- case .small: 28
- case .regular: 36
- case .large: 52
- }
- }
- var iconSize: CGFloat {
- switch self {
- case .small: 12
- case .regular: 16
- case .large: 24
- }
- }
- }
- @State private var isPressed = false
- var body: some View {
- Button(action: action) {
- Image(systemName: icon)
- .font(.system(size: size.iconSize, weight: .semibold))
- .foregroundStyle(isActive ? accentColor : Color(white: 0.7))
- .frame(width: size.dimension, height: size.dimension)
- .background(
- ZStack {
- // Base
- RoundedRectangle(cornerRadius: size.dimension * 0.2)
- .fill(
- LinearGradient(
- colors: isPressed
- ? [Color(white: 0.12), Color(white: 0.16)]
- : [Color(white: 0.22), Color(white: 0.14)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- // Border
- RoundedRectangle(cornerRadius: size.dimension * 0.2)
- .stroke(
- LinearGradient(
- colors: [Color(white: 0.3), Color(white: 0.1)],
- startPoint: .top,
- endPoint: .bottom
- ),
- lineWidth: 1
- )
- }
- )
- .shadow(
- color: isActive ? accentColor.opacity(0.3) : .clear,
- radius: 4
- )
- .shadow(
- color: Color.black.opacity(isPressed ? 0 : 0.5),
- radius: isPressed ? 0 : 2,
- y: isPressed ? 0 : 2
- )
- .scaleEffect(isPressed ? 0.95 : 1.0)
- .animation(.easeOut(duration: 0.1), value: isPressed)
- }
- .buttonStyle(.plain)
- .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
- isPressed = pressing
- }, perform: {})
- }
- }
- // MARK: - Fader
- /// Vertical fader for EQ bands. Drag to adjust.
- struct FaderView: View {
- @Binding var value: Float // -1...1 for EQ, or 0...1 for volume
- var label: String = ""
- var range: ClosedRange<Float> = -1...1
- var height: CGFloat = 80
- var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
- @State private var isDragging = false
- @State private var dragStartValue: Float = 0
- private var normalizedValue: CGFloat {
- CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound))
- }
- var body: some View {
- VStack(spacing: 4) {
- ZStack(alignment: .bottom) {
- // Track groove
- RoundedRectangle(cornerRadius: 2)
- .fill(Color(white: 0.08))
- .frame(width: 6, height: height)
- .overlay(
- RoundedRectangle(cornerRadius: 2)
- .stroke(Color(white: 0.2), lineWidth: 0.5)
- )
- // Value fill
- RoundedRectangle(cornerRadius: 2)
- .fill(accentColor.opacity(0.5))
- .frame(width: 6, height: height * normalizedValue)
- // Fader cap
- RoundedRectangle(cornerRadius: 3)
- .fill(
- LinearGradient(
- colors: [Color(white: 0.45), Color(white: 0.25)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- .frame(width: 20, height: 12)
- .shadow(color: .black.opacity(0.4), radius: 2, y: 1)
- .offset(y: -height * normalizedValue + 6)
- }
- .frame(width: 20, height: height)
- .gesture(
- DragGesture(minimumDistance: 1)
- .onChanged { gesture in
- if !isDragging {
- isDragging = true
- dragStartValue = value
- }
- let delta = Float(-gesture.translation.height / height)
- * (range.upperBound - range.lowerBound)
- value = max(range.lowerBound, min(range.upperBound, dragStartValue + delta))
- }
- .onEnded { _ in isDragging = false }
- )
- if !label.isEmpty {
- Text(label)
- .font(.system(size: 8, weight: .bold, design: .monospaced))
- .foregroundStyle(Color(white: 0.5))
- }
- }
- }
- }
- // MARK: - DJ Section Background
- /// Textured surface for grouping DJ controls.
- struct DJSectionBackground: View {
- var cornerRadius: CGFloat = 8
- var body: some View {
- RoundedRectangle(cornerRadius: cornerRadius)
- .fill(
- LinearGradient(
- colors: [
- Color(red: 0.06, green: 0.06, blue: 0.08),
- Color(red: 0.04, green: 0.04, blue: 0.06),
- ],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: cornerRadius)
- .stroke(
- LinearGradient(
- colors: [Color(white: 0.15), Color(white: 0.05)],
- startPoint: .top,
- endPoint: .bottom
- ),
- lineWidth: 1
- )
- )
- .shadow(color: .black.opacity(0.5), radius: 4, y: 2)
- }
- }
- // MARK: - Vinyl Spin Animation
- /// Album art with vinyl grooves that spins during playback.
- struct VinylSpinView: View {
- let trackTitle: String
- let artworkView: AnyView?
- var isPlaying: Bool
- var size: CGFloat = 120
- @State private var rotation: Double = 0
- var body: some View {
- ZStack {
- // Vinyl disc
- Circle()
- .fill(
- RadialGradient(
- colors: [
- Color(white: 0.08),
- Color(white: 0.04),
- Color(white: 0.06),
- Color(white: 0.03),
- Color(white: 0.05),
- ],
- center: .center,
- startRadius: size * 0.2,
- endRadius: size * 0.5
- )
- )
- .frame(width: size, height: size)
- // Grooves (concentric rings)
- ForEach(0..<6, id: \.self) { ring in
- let ringRadius = size * 0.22 + CGFloat(ring) * (size * 0.045)
- Circle()
- .stroke(Color(white: 0.1), lineWidth: 0.5)
- .frame(width: ringRadius * 2, height: ringRadius * 2)
- }
- // Center label (album art or placeholder)
- if let artwork = artworkView {
- artwork
- .frame(width: size * 0.35, height: size * 0.35)
- .clipShape(Circle())
- } else {
- Circle()
- .fill(Color(white: 0.12))
- .frame(width: size * 0.35, height: size * 0.35)
- .overlay(
- Text(String(trackTitle.prefix(2)).uppercased())
- .font(.system(size: size * 0.08, weight: .bold, design: .monospaced))
- .foregroundStyle(Color(white: 0.4))
- )
- }
- // Spindle hole
- Circle()
- .fill(Color(white: 0.02))
- .frame(width: size * 0.05, height: size * 0.05)
- }
- .rotationEffect(.degrees(rotation))
- .onChange(of: isPlaying) { _, playing in
- if playing {
- withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
- rotation += 360
- }
- } else {
- // Stop smoothly — remove repeating animation
- withAnimation(.easeOut(duration: 0.5)) {
- // Keep current rotation (no reset)
- }
- }
- }
- .onAppear {
- if isPlaying {
- withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
- rotation = 360
- }
- }
- }
- }
- }
|