|
@@ -0,0 +1,212 @@
|
|
|
|
|
+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
|
|
|
|
|
+ }
|
|
|
|
|
+}
|