DJComponents.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import SwiftUI
  2. // MARK: - Rotary Knob
  3. /// A hardware-inspired rotary knob control. Drag vertically to adjust value.
  4. struct RotaryKnobView: View {
  5. @Binding var value: Float // 0...1
  6. var label: String = ""
  7. var size: CGFloat = 48
  8. var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
  9. @State private var isDragging = false
  10. @State private var dragStartValue: Float = 0
  11. // Knob rotates from -135deg to +135deg (270deg total range)
  12. private var rotationAngle: Double {
  13. Double(value) * 270 - 135
  14. }
  15. var body: some View {
  16. VStack(spacing: 4) {
  17. ZStack {
  18. // Outer ring — brushed metal
  19. Circle()
  20. .fill(
  21. AngularGradient(
  22. colors: [
  23. Color(white: 0.25), Color(white: 0.35),
  24. Color(white: 0.20), Color(white: 0.30),
  25. Color(white: 0.25),
  26. ],
  27. center: .center
  28. )
  29. )
  30. .frame(width: size, height: size)
  31. // Value arc
  32. Circle()
  33. .trim(from: 0, to: CGFloat(value) * 0.75)
  34. .rotation(.degrees(135))
  35. .stroke(
  36. accentColor,
  37. style: StrokeStyle(lineWidth: 3, lineCap: .round)
  38. )
  39. .frame(width: size + 6, height: size + 6)
  40. .shadow(color: accentColor.opacity(0.6), radius: 4)
  41. // Inner circle — darker center
  42. Circle()
  43. .fill(
  44. RadialGradient(
  45. colors: [Color(white: 0.18), Color(white: 0.12)],
  46. center: .center,
  47. startRadius: 0,
  48. endRadius: size * 0.35
  49. )
  50. )
  51. .frame(width: size * 0.7, height: size * 0.7)
  52. // Position indicator line
  53. Rectangle()
  54. .fill(accentColor)
  55. .frame(width: 2, height: size * 0.25)
  56. .offset(y: -size * 0.2)
  57. .rotationEffect(.degrees(rotationAngle))
  58. .shadow(color: accentColor.opacity(0.8), radius: 2)
  59. }
  60. .gesture(
  61. DragGesture(minimumDistance: 1)
  62. .onChanged { gesture in
  63. if !isDragging {
  64. isDragging = true
  65. dragStartValue = value
  66. }
  67. // Vertical drag: up = increase, down = decrease
  68. let delta = Float(-gesture.translation.height / 150)
  69. value = max(0, min(1, dragStartValue + delta))
  70. }
  71. .onEnded { _ in isDragging = false }
  72. )
  73. if !label.isEmpty {
  74. Text(label)
  75. .font(.system(size: 9, weight: .bold, design: .monospaced))
  76. .foregroundStyle(Color(white: 0.5))
  77. }
  78. }
  79. }
  80. }
  81. // MARK: - VU Meter
  82. /// Vertical LED-strip level meter. Green → Yellow → Red.
  83. struct VUMeterView: View {
  84. var level: Float // 0...1
  85. var segmentCount: Int = 12
  86. var width: CGFloat = 8
  87. var height: CGFloat = 60
  88. var body: some View {
  89. VStack(spacing: 1.5) {
  90. ForEach((0..<segmentCount).reversed(), id: \.self) { index in
  91. let threshold = Float(index) / Float(segmentCount)
  92. let isLit = level > threshold
  93. let color = segmentColor(index: index)
  94. RoundedRectangle(cornerRadius: 1)
  95. .fill(isLit ? color : color.opacity(0.15))
  96. .frame(width: width, height: max(2, height / CGFloat(segmentCount) - 1.5))
  97. .shadow(color: isLit ? color.opacity(0.5) : .clear, radius: 2)
  98. }
  99. }
  100. }
  101. private func segmentColor(index: Int) -> Color {
  102. let ratio = Float(index) / Float(segmentCount)
  103. if ratio >= 0.83 { return Color(red: 1.0, green: 0.1, blue: 0.1) } // Red
  104. if ratio >= 0.66 { return Color(red: 1.0, green: 0.8, blue: 0.0) } // Yellow
  105. return Color(red: 0.0, green: 0.85, blue: 0.4) // Green
  106. }
  107. }
  108. // MARK: - LED Display
  109. /// Seven-segment-style glowing text display for BPM, time, key.
  110. struct LEDDisplay: View {
  111. let text: String
  112. var fontSize: CGFloat = 16
  113. var color: Color = Color(red: 0, green: 0.83, blue: 1.0)
  114. var alignment: Alignment = .center
  115. var body: some View {
  116. Text(text)
  117. .font(.system(size: fontSize, weight: .bold, design: .monospaced))
  118. .foregroundStyle(color)
  119. .shadow(color: color.opacity(0.7), radius: 4)
  120. .shadow(color: color.opacity(0.3), radius: 8)
  121. .frame(maxWidth: .infinity, alignment: alignment)
  122. .padding(.horizontal, 6)
  123. .padding(.vertical, 3)
  124. .background(
  125. RoundedRectangle(cornerRadius: 4)
  126. .fill(Color(white: 0.05))
  127. .overlay(
  128. RoundedRectangle(cornerRadius: 4)
  129. .stroke(Color(white: 0.15), lineWidth: 0.5)
  130. )
  131. )
  132. }
  133. }
  134. // MARK: - DJ Transport Button
  135. /// Hardware-inspired raised button for transport controls.
  136. struct DJTransportButton: View {
  137. let icon: String
  138. var size: ButtonSize = .regular
  139. var isActive: Bool = false
  140. var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
  141. let action: () -> Void
  142. enum ButtonSize {
  143. case small, regular, large
  144. var dimension: CGFloat {
  145. switch self {
  146. case .small: 28
  147. case .regular: 36
  148. case .large: 52
  149. }
  150. }
  151. var iconSize: CGFloat {
  152. switch self {
  153. case .small: 12
  154. case .regular: 16
  155. case .large: 24
  156. }
  157. }
  158. }
  159. @State private var isPressed = false
  160. var body: some View {
  161. Button(action: action) {
  162. Image(systemName: icon)
  163. .font(.system(size: size.iconSize, weight: .semibold))
  164. .foregroundStyle(isActive ? accentColor : Color(white: 0.7))
  165. .frame(width: size.dimension, height: size.dimension)
  166. .background(
  167. ZStack {
  168. // Base
  169. RoundedRectangle(cornerRadius: size.dimension * 0.2)
  170. .fill(
  171. LinearGradient(
  172. colors: isPressed
  173. ? [Color(white: 0.12), Color(white: 0.16)]
  174. : [Color(white: 0.22), Color(white: 0.14)],
  175. startPoint: .top,
  176. endPoint: .bottom
  177. )
  178. )
  179. // Border
  180. RoundedRectangle(cornerRadius: size.dimension * 0.2)
  181. .stroke(
  182. LinearGradient(
  183. colors: [Color(white: 0.3), Color(white: 0.1)],
  184. startPoint: .top,
  185. endPoint: .bottom
  186. ),
  187. lineWidth: 1
  188. )
  189. }
  190. )
  191. .shadow(
  192. color: isActive ? accentColor.opacity(0.3) : .clear,
  193. radius: 4
  194. )
  195. .shadow(
  196. color: Color.black.opacity(isPressed ? 0 : 0.5),
  197. radius: isPressed ? 0 : 2,
  198. y: isPressed ? 0 : 2
  199. )
  200. .scaleEffect(isPressed ? 0.95 : 1.0)
  201. .animation(.easeOut(duration: 0.1), value: isPressed)
  202. }
  203. .buttonStyle(.plain)
  204. .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
  205. isPressed = pressing
  206. }, perform: {})
  207. }
  208. }
  209. // MARK: - Fader
  210. /// Vertical fader for EQ bands. Drag to adjust.
  211. struct FaderView: View {
  212. @Binding var value: Float // -1...1 for EQ, or 0...1 for volume
  213. var label: String = ""
  214. var range: ClosedRange<Float> = -1...1
  215. var height: CGFloat = 80
  216. var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
  217. @State private var isDragging = false
  218. @State private var dragStartValue: Float = 0
  219. private var normalizedValue: CGFloat {
  220. CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound))
  221. }
  222. var body: some View {
  223. VStack(spacing: 4) {
  224. ZStack(alignment: .bottom) {
  225. // Track groove
  226. RoundedRectangle(cornerRadius: 2)
  227. .fill(Color(white: 0.08))
  228. .frame(width: 6, height: height)
  229. .overlay(
  230. RoundedRectangle(cornerRadius: 2)
  231. .stroke(Color(white: 0.2), lineWidth: 0.5)
  232. )
  233. // Value fill
  234. RoundedRectangle(cornerRadius: 2)
  235. .fill(accentColor.opacity(0.5))
  236. .frame(width: 6, height: height * normalizedValue)
  237. // Fader cap
  238. RoundedRectangle(cornerRadius: 3)
  239. .fill(
  240. LinearGradient(
  241. colors: [Color(white: 0.45), Color(white: 0.25)],
  242. startPoint: .top,
  243. endPoint: .bottom
  244. )
  245. )
  246. .frame(width: 20, height: 12)
  247. .shadow(color: .black.opacity(0.4), radius: 2, y: 1)
  248. .offset(y: -height * normalizedValue + 6)
  249. }
  250. .frame(width: 20, height: height)
  251. .gesture(
  252. DragGesture(minimumDistance: 1)
  253. .onChanged { gesture in
  254. if !isDragging {
  255. isDragging = true
  256. dragStartValue = value
  257. }
  258. let delta = Float(-gesture.translation.height / height)
  259. * (range.upperBound - range.lowerBound)
  260. value = max(range.lowerBound, min(range.upperBound, dragStartValue + delta))
  261. }
  262. .onEnded { _ in isDragging = false }
  263. )
  264. if !label.isEmpty {
  265. Text(label)
  266. .font(.system(size: 8, weight: .bold, design: .monospaced))
  267. .foregroundStyle(Color(white: 0.5))
  268. }
  269. }
  270. }
  271. }
  272. // MARK: - DJ Section Background
  273. /// Textured surface for grouping DJ controls.
  274. struct DJSectionBackground: View {
  275. var cornerRadius: CGFloat = 8
  276. var body: some View {
  277. RoundedRectangle(cornerRadius: cornerRadius)
  278. .fill(
  279. LinearGradient(
  280. colors: [
  281. Color(red: 0.06, green: 0.06, blue: 0.08),
  282. Color(red: 0.04, green: 0.04, blue: 0.06),
  283. ],
  284. startPoint: .top,
  285. endPoint: .bottom
  286. )
  287. )
  288. .overlay(
  289. RoundedRectangle(cornerRadius: cornerRadius)
  290. .stroke(
  291. LinearGradient(
  292. colors: [Color(white: 0.15), Color(white: 0.05)],
  293. startPoint: .top,
  294. endPoint: .bottom
  295. ),
  296. lineWidth: 1
  297. )
  298. )
  299. .shadow(color: .black.opacity(0.5), radius: 4, y: 2)
  300. }
  301. }
  302. // MARK: - Vinyl Spin Animation
  303. /// Album art with vinyl grooves that spins during playback.
  304. struct VinylSpinView: View {
  305. let trackTitle: String
  306. let artworkView: AnyView?
  307. var isPlaying: Bool
  308. var size: CGFloat = 120
  309. @State private var rotation: Double = 0
  310. var body: some View {
  311. ZStack {
  312. // Vinyl disc
  313. Circle()
  314. .fill(
  315. RadialGradient(
  316. colors: [
  317. Color(white: 0.08),
  318. Color(white: 0.04),
  319. Color(white: 0.06),
  320. Color(white: 0.03),
  321. Color(white: 0.05),
  322. ],
  323. center: .center,
  324. startRadius: size * 0.2,
  325. endRadius: size * 0.5
  326. )
  327. )
  328. .frame(width: size, height: size)
  329. // Grooves (concentric rings)
  330. ForEach(0..<6, id: \.self) { ring in
  331. let ringRadius = size * 0.22 + CGFloat(ring) * (size * 0.045)
  332. Circle()
  333. .stroke(Color(white: 0.1), lineWidth: 0.5)
  334. .frame(width: ringRadius * 2, height: ringRadius * 2)
  335. }
  336. // Center label (album art or placeholder)
  337. if let artwork = artworkView {
  338. artwork
  339. .frame(width: size * 0.35, height: size * 0.35)
  340. .clipShape(Circle())
  341. } else {
  342. Circle()
  343. .fill(Color(white: 0.12))
  344. .frame(width: size * 0.35, height: size * 0.35)
  345. .overlay(
  346. Text(String(trackTitle.prefix(2)).uppercased())
  347. .font(.system(size: size * 0.08, weight: .bold, design: .monospaced))
  348. .foregroundStyle(Color(white: 0.4))
  349. )
  350. }
  351. // Spindle hole
  352. Circle()
  353. .fill(Color(white: 0.02))
  354. .frame(width: size * 0.05, height: size * 0.05)
  355. }
  356. .rotationEffect(.degrees(rotation))
  357. .onChange(of: isPlaying) { _, playing in
  358. if playing {
  359. withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
  360. rotation += 360
  361. }
  362. } else {
  363. // Stop smoothly — remove repeating animation
  364. withAnimation(.easeOut(duration: 0.5)) {
  365. // Keep current rotation (no reset)
  366. }
  367. }
  368. }
  369. .onAppear {
  370. if isPlaying {
  371. withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
  372. rotation = 360
  373. }
  374. }
  375. }
  376. }
  377. }