import SwiftUI /// Half-sheet EQ panel with three vertical sliders, preset picker, and reset button. struct EQView: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { @Bindable var vm = playerVM VStack(spacing: 24) { // Header: title + preset picker HStack { Text("Equalizer") .font(.title3.bold()) .foregroundStyle(theme.primaryText) Spacer() Menu { ForEach(EQPreset.allCases.filter { $0 != .custom }) { preset in Button { playerVM.applyEQPreset(preset) } label: { HStack { Text(preset.displayName) if playerVM.eqPreset == preset { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Text(playerVM.eqPreset.displayName) .font(.subheadline.weight(.medium)) Image(systemName: "chevron.up.chevron.down") .font(.caption2) } .foregroundStyle(theme.accent) } .accessibilityIdentifier("eqPresetPicker") } .padding(.horizontal, 24) // Three vertical sliders side by side HStack(spacing: 0) { VerticalEQSlider( label: "LOW", value: $vm.eqLow, onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) } ) VerticalEQSlider( label: "MID", value: $vm.eqMid, onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) } ) VerticalEQSlider( label: "HIGH", value: $vm.eqHigh, onChange: { playerVM.setEQ(low: playerVM.eqLow, mid: playerVM.eqMid, high: playerVM.eqHigh) } ) } .padding(.horizontal, 16) // Reset button Button { withAnimation(.easeInOut(duration: 0.2)) { playerVM.resetEQ() } } label: { Text("Reset") .font(.subheadline.weight(.medium)) .foregroundStyle(playerVM.isEQActive ? theme.accent : theme.secondaryText) .padding(.horizontal, 20) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) .fill(theme.secondaryBackground) ) } .disabled(!playerVM.isEQActive) .accessibilityIdentifier("eqResetButton") Spacer() } .padding(.top, 20) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(theme.secondaryBackground) .presentationDetents([.medium]) .presentationDragIndicator(.visible) } } // MARK: - Vertical EQ Slider /// Custom vertical slider for a single EQ band. /// Range: -24 to +12 dB with visual center line at 0 dB. /// Features: dB readout, haptic at 0 dB crossing, double-tap reset. private struct VerticalEQSlider: View { let label: String @Binding var value: Float let onChange: () -> Void @EnvironmentObject private var theme: AppTheme @State private var lastHapticSide: HapticSide = .center private enum HapticSide { case negative, center, positive } private static let minDB: Float = -24 private static let maxDB: Float = 12 private static let range: Float = maxDB - minDB // 36 var body: some View { VStack(spacing: 8) { // dB readout Text(formatGain(value)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(theme.primaryText) .frame(height: 16) // Slider track GeometryReader { geo in let height = geo.size.height let centerY = height * CGFloat(Self.maxDB / Self.range) // 0 dB position from top let thumbY = height * CGFloat((Self.maxDB - value) / Self.range) ZStack(alignment: .top) { // Track background RoundedRectangle(cornerRadius: 3) .fill(theme.seekbarBackground) .frame(width: 6) .frame(maxHeight: .infinity) // Center line at 0 dB Rectangle() .fill(theme.separatorColor) .frame(width: 24, height: 1) .offset(y: centerY) // Fill from center to thumb let fillStart = min(centerY, thumbY) let fillHeight = abs(thumbY - centerY) RoundedRectangle(cornerRadius: 3) .fill(theme.accent) .frame(width: 6, height: fillHeight) .offset(y: fillStart) // Thumb indicator Circle() .fill(theme.accent) .frame(width: 20, height: 20) .shadow(color: .black.opacity(0.3), radius: 2, y: 1) .offset(y: thumbY - 10) } .frame(maxWidth: .infinity) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { drag in let fraction = Float(drag.location.y / height) let newValue = Self.maxDB - fraction * Self.range let clamped = min(Self.maxDB, max(Self.minDB, newValue)) value = clamped checkHaptic(clamped) onChange() } ) .onTapGesture(count: 2) { withAnimation(.easeInOut(duration: 0.2)) { value = 0 } lastHapticSide = .center onChange() } } // Band label Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(theme.secondaryText) } .frame(maxWidth: .infinity) .onAppear { lastHapticSide = value > 0 ? .positive : value < 0 ? .negative : .center } } private func formatGain(_ db: Float) -> String { if db == 0 { return "0 dB" } let sign = db > 0 ? "+" : "" // Show integer if whole number, otherwise one decimal if db == db.rounded() { return "\(sign)\(Int(db)) dB" } return "\(sign)\(String(format: "%.1f", db)) dB" } /// Fire haptic when crossing the 0 dB center line. private func checkHaptic(_ newValue: Float) { let newSide: HapticSide = newValue > 0.5 ? .positive : newValue < -0.5 ? .negative : .center let crossed = (lastHapticSide == .positive && newSide == .negative) || (lastHapticSide == .negative && newSide == .positive) || (lastHapticSide != .center && newSide == .center) if crossed { UIImpactFeedbackGenerator(style: .light).impactOccurred() } lastHapticSide = newSide } }