EQView.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import SwiftUI
  2. /// Half-sheet EQ panel with three vertical sliders, preset picker, and reset button.
  3. struct EQView: View {
  4. @Environment(PlayerViewModel.self) private var playerVM
  5. @EnvironmentObject private var theme: AppTheme
  6. var body: some View {
  7. @Bindable var vm = playerVM
  8. VStack(spacing: 24) {
  9. // Header: title + preset picker
  10. HStack {
  11. Text("Equalizer")
  12. .font(.title3.bold())
  13. .foregroundStyle(theme.primaryText)
  14. Spacer()
  15. Menu {
  16. ForEach(EQPreset.allCases.filter { $0 != .custom }) { preset in
  17. Button {
  18. playerVM.applyEQPreset(preset)
  19. } label: {
  20. HStack {
  21. Text(preset.displayName)
  22. if playerVM.eqPreset == preset {
  23. Image(systemName: "checkmark")
  24. }
  25. }
  26. }
  27. }
  28. } label: {
  29. HStack(spacing: 4) {
  30. Text(playerVM.eqPreset.displayName)
  31. .font(.subheadline.weight(.medium))
  32. Image(systemName: "chevron.up.chevron.down")
  33. .font(.caption2)
  34. }
  35. .foregroundStyle(theme.accent)
  36. }
  37. .accessibilityIdentifier("eqPresetPicker")
  38. }
  39. .padding(.horizontal, 24)
  40. // Three vertical sliders side by side
  41. HStack(spacing: 0) {
  42. VerticalEQSlider(
  43. label: "LOW",
  44. value: $vm.eqLow,
  45. onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) }
  46. )
  47. VerticalEQSlider(
  48. label: "MID",
  49. value: $vm.eqMid,
  50. onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) }
  51. )
  52. VerticalEQSlider(
  53. label: "HIGH",
  54. value: $vm.eqHigh,
  55. onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) }
  56. )
  57. }
  58. .padding(.horizontal, 16)
  59. // Reset button
  60. Button {
  61. withAnimation(.easeInOut(duration: 0.2)) {
  62. playerVM.resetEQ()
  63. }
  64. } label: {
  65. Text("Reset")
  66. .font(.subheadline.weight(.medium))
  67. .foregroundStyle(playerVM.isEQActive ? theme.accent : theme.secondaryText)
  68. .padding(.horizontal, 20)
  69. .padding(.vertical, 8)
  70. .background(
  71. RoundedRectangle(cornerRadius: 8)
  72. .fill(theme.secondaryBackground)
  73. )
  74. }
  75. .disabled(!playerVM.isEQActive)
  76. .accessibilityIdentifier("eqResetButton")
  77. Spacer()
  78. }
  79. .padding(.top, 20)
  80. .frame(maxWidth: .infinity, maxHeight: .infinity)
  81. .background(theme.secondaryBackground)
  82. .presentationDetents([.medium])
  83. .presentationDragIndicator(.visible)
  84. }
  85. }
  86. // MARK: - Vertical EQ Slider
  87. /// Custom vertical slider for a single EQ band.
  88. /// Range: -24 to +12 dB with visual center line at 0 dB.
  89. /// Features: dB readout, haptic at 0 dB crossing, double-tap reset.
  90. private struct VerticalEQSlider: View {
  91. let label: String
  92. @Binding var value: Float
  93. let onChange: () -> Void
  94. @EnvironmentObject private var theme: AppTheme
  95. @State private var lastHapticSide: HapticSide = .center
  96. private enum HapticSide { case negative, center, positive }
  97. private static let minDB: Float = -24
  98. private static let maxDB: Float = 12
  99. private static let range: Float = maxDB - minDB // 36
  100. var body: some View {
  101. VStack(spacing: 8) {
  102. // dB readout
  103. Text(formatGain(value))
  104. .font(.system(.caption, design: .monospaced))
  105. .foregroundStyle(theme.primaryText)
  106. .frame(height: 16)
  107. // Slider track
  108. GeometryReader { geo in
  109. let height = geo.size.height
  110. let centerY = height * CGFloat(Self.maxDB / Self.range) // 0 dB position from top
  111. let thumbY = height * CGFloat((Self.maxDB - value) / Self.range)
  112. ZStack(alignment: .top) {
  113. // Track background
  114. RoundedRectangle(cornerRadius: 3)
  115. .fill(theme.seekbarBackground)
  116. .frame(width: 6)
  117. .frame(maxHeight: .infinity)
  118. // Center line at 0 dB
  119. Rectangle()
  120. .fill(theme.separatorColor)
  121. .frame(width: 24, height: 1)
  122. .offset(y: centerY)
  123. // Fill from center to thumb
  124. let fillStart = min(centerY, thumbY)
  125. let fillHeight = abs(thumbY - centerY)
  126. RoundedRectangle(cornerRadius: 3)
  127. .fill(theme.accent)
  128. .frame(width: 6, height: fillHeight)
  129. .offset(y: fillStart)
  130. // Thumb indicator
  131. Circle()
  132. .fill(theme.accent)
  133. .frame(width: 20, height: 20)
  134. .shadow(color: .black.opacity(0.3), radius: 2, y: 1)
  135. .offset(y: thumbY - 10)
  136. }
  137. .frame(maxWidth: .infinity)
  138. .contentShape(Rectangle())
  139. .gesture(
  140. DragGesture(minimumDistance: 0)
  141. .onChanged { drag in
  142. let fraction = Float(drag.location.y / height)
  143. let newValue = Self.maxDB - fraction * Self.range
  144. let clamped = min(Self.maxDB, max(Self.minDB, newValue))
  145. value = clamped
  146. checkHaptic(clamped)
  147. onChange()
  148. }
  149. )
  150. .onTapGesture(count: 2) {
  151. withAnimation(.easeInOut(duration: 0.2)) {
  152. value = 0
  153. }
  154. lastHapticSide = .center
  155. onChange()
  156. }
  157. }
  158. // Band label
  159. Text(label)
  160. .font(.caption.weight(.semibold))
  161. .foregroundStyle(theme.secondaryText)
  162. }
  163. .frame(maxWidth: .infinity)
  164. .onAppear {
  165. lastHapticSide = value > 0 ? .positive : value < 0 ? .negative : .center
  166. }
  167. }
  168. private func formatGain(_ db: Float) -> String {
  169. if db == 0 { return "0 dB" }
  170. let sign = db > 0 ? "+" : ""
  171. // Show integer if whole number, otherwise one decimal
  172. if db == db.rounded() {
  173. return "\(sign)\(Int(db)) dB"
  174. }
  175. return "\(sign)\(String(format: "%.1f", db)) dB"
  176. }
  177. /// Fire haptic when crossing the 0 dB center line.
  178. private func checkHaptic(_ newValue: Float) {
  179. let newSide: HapticSide = newValue > 0.5 ? .positive : newValue < -0.5 ? .negative : .center
  180. let crossed = (lastHapticSide == .positive && newSide == .negative) ||
  181. (lastHapticSide == .negative && newSide == .positive) ||
  182. (lastHapticSide != .center && newSide == .center)
  183. if crossed {
  184. UIImpactFeedbackGenerator(style: .light).impactOccurred()
  185. }
  186. lastHapticSide = newSide
  187. }
  188. }