| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- 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
- }
- }
|