Selaa lähdekoodia

feat: add 3-band EQ UI with presets, persistence, and theme support

New files:
- EQPreset.swift: 5 named presets (Flat, Bass Boost, Bass Cut, Loudness, Vocal Focus)
  with auto-detection and Custom fallback
- EQView.swift: half-sheet with three vertical sliders (-24 to +12 dB),
  preset picker, reset button, haptic at 0 dB crossing, double-tap reset

Modified:
- PlayerViewModel: EQ state (eqLow/eqMid/eqHigh), setEQ/applyEQPreset/resetEQ,
  UserDefaults persistence, restoreEQ on init
- NowPlayingView: EQ toolbar button (slider.vertical.3) between lyrics and
  overflow menu, disabled for cloud playback, accent color when active

AudioEngine.setEQ(band:gain:) was already implemented — this is pure UI wiring.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
aldiss 2 kuukautta sitten
vanhempi
commit
fb471de269

+ 51 - 0
Sources/Models/EQPreset.swift

@@ -0,0 +1,51 @@
+import Foundation
+
+/// Built-in EQ presets with predefined gain values for 3-band EQ.
+/// "Custom" is auto-assigned when slider values don't match any named preset.
+enum EQPreset: String, CaseIterable, Identifiable {
+    case flat
+    case bassBoost
+    case bassCut
+    case loudness
+    case vocalFocus
+    case custom
+
+    var id: String { rawValue }
+
+    /// Human-readable label for the preset picker.
+    var displayName: String {
+        switch self {
+        case .flat: "Flat"
+        case .bassBoost: "Bass Boost"
+        case .bassCut: "Bass Cut"
+        case .loudness: "Loudness"
+        case .vocalFocus: "Vocal Focus"
+        case .custom: "Custom"
+        }
+    }
+
+    /// Gain values (low, mid, high) for each named preset.
+    /// Returns nil for `.custom` since it has no fixed values.
+    var gains: (low: Float, mid: Float, high: Float)? {
+        switch self {
+        case .flat:       (0, 0, 0)
+        case .bassBoost:  (6, 0, 0)
+        case .bassCut:    (-24, 0, 0)
+        case .loudness:   (4, -2, 4)
+        case .vocalFocus: (-4, 4, -2)
+        case .custom:     nil
+        }
+    }
+
+    /// Auto-detect which preset matches the given gain values,
+    /// or return `.custom` if no match.
+    static func detect(low: Float, mid: Float, high: Float) -> EQPreset {
+        for preset in Self.allCases where preset != .custom {
+            guard let gains = preset.gains else { continue }
+            if gains.low == low && gains.mid == mid && gains.high == high {
+                return preset
+            }
+        }
+        return .custom
+    }
+}

+ 55 - 0
Sources/ViewModels/PlayerViewModel.swift

@@ -19,6 +19,23 @@ final class PlayerViewModel {
     var isLoadingWaveform = false
     var showNowPlaying = false
     var showQueue = false
+    var showEQ = false
+
+    // MARK: - EQ State (persisted to UserDefaults)
+
+    var eqLow: Float = 0
+    var eqMid: Float = 0
+    var eqHigh: Float = 0
+
+    /// Current EQ preset — auto-detected from slider values.
+    var eqPreset: EQPreset {
+        EQPreset.detect(low: eqLow, mid: eqMid, high: eqHigh)
+    }
+
+    /// True when any EQ band is non-zero.
+    var isEQActive: Bool {
+        eqLow != 0 || eqMid != 0 || eqHigh != 0
+    }
 
     /// ID of the currently playing playlist entry.
     var currentPlayingEntryID: UUID?
@@ -118,6 +135,7 @@ final class PlayerViewModel {
 
     init() {
         restoreQueue()
+        restoreEQ()
         startSyncTimer()
         audioEngine.onPlaybackFinished = { [weak self] in
             self?.playNext()
@@ -646,6 +664,43 @@ final class PlayerViewModel {
         }
     }
 
+    // MARK: - EQ Controls
+
+    /// Apply persisted EQ values to the audio engine on startup.
+    private func restoreEQ() {
+        eqLow = UserDefaults.standard.float(forKey: "eq.low")
+        eqMid = UserDefaults.standard.float(forKey: "eq.mid")
+        eqHigh = UserDefaults.standard.float(forKey: "eq.high")
+        audioEngine.setEQ(band: 0, gain: eqLow)
+        audioEngine.setEQ(band: 1, gain: eqMid)
+        audioEngine.setEQ(band: 2, gain: eqHigh)
+    }
+
+    /// Set all three EQ bands and update the audio engine.
+    func setEQ(low: Float, mid: Float, high: Float) {
+        eqLow = low
+        eqMid = mid
+        eqHigh = high
+        audioEngine.setEQ(band: 0, gain: low)
+        audioEngine.setEQ(band: 1, gain: mid)
+        audioEngine.setEQ(band: 2, gain: high)
+        // Persist to UserDefaults
+        UserDefaults.standard.set(low, forKey: "eq.low")
+        UserDefaults.standard.set(mid, forKey: "eq.mid")
+        UserDefaults.standard.set(high, forKey: "eq.high")
+    }
+
+    /// Apply a named preset.
+    func applyEQPreset(_ preset: EQPreset) {
+        guard let gains = preset.gains else { return }
+        setEQ(low: gains.low, mid: gains.mid, high: gains.high)
+    }
+
+    /// Reset all bands to flat (0 dB).
+    func resetEQ() {
+        applyEQPreset(.flat)
+    }
+
     // MARK: - Helpers
 
     private func formatTime(_ time: TimeInterval) -> String {

+ 212 - 0
Sources/Views/EQView.swift

@@ -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
+    }
+}

+ 18 - 0
Sources/Views/NowPlayingView.swift

@@ -113,6 +113,19 @@ struct NowPlayingView: View {
                         }
                         .accessibilityIdentifier("lyricsButton")
 
+                        // EQ button — disabled for cloud tracks (no AVAudioEngine EQ)
+                        Button {
+                            playerVM.showEQ = true
+                        } label: {
+                            Image(systemName: "slider.vertical.3")
+                                .foregroundStyle(
+                                    playerVM.isEQActive ? theme.accent : theme.secondaryText
+                                )
+                        }
+                        .disabled(playerVM.isCloudPlayback)
+                        .opacity(playerVM.isCloudPlayback ? 0.3 : 1.0)
+                        .accessibilityIdentifier("eqButton")
+
                         Menu {
                             if let track = playerVM.currentTrack {
                                 Button {
@@ -141,6 +154,11 @@ struct NowPlayingView: View {
                 }
             }
             .toolbarBackground(theme.background, for: .navigationBar)
+            .sheet(isPresented: Bindable(playerVM).showEQ) {
+                EQView()
+                    .environment(playerVM)
+                    .environmentObject(theme)
+            }
             .onChange(of: playerVM.currentTrack?.id) { _, newID in
                 if newID != lastLoadedTrackID {
                     loadLyrics()