progress
This commit is contained in:
@@ -23,6 +23,9 @@
|
||||
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */; };
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; };
|
||||
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; };
|
||||
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; };
|
||||
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; };
|
||||
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */; };
|
||||
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E6119B2BC72C0E0036BC47 /* DetailView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -58,6 +61,9 @@
|
||||
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
|
||||
98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = "<group>"; };
|
||||
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = "<group>"; };
|
||||
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNewAudioURLView.swift; sourceTree = "<group>"; };
|
||||
98E6119B2BC72C0E0036BC47 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -94,6 +100,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98BFB41B2BCAAD8A00E812C0 /* Info.plist */,
|
||||
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
|
||||
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
|
||||
9816A8A82BC7F4DE00AD1299 /* Common */,
|
||||
@@ -130,6 +137,8 @@
|
||||
children = (
|
||||
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */,
|
||||
9806E8292BC68F8700757370 /* AudioPlayerView.swift */,
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */,
|
||||
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
@@ -235,6 +244,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */,
|
||||
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */,
|
||||
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */,
|
||||
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */,
|
||||
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */,
|
||||
@@ -242,6 +253,7 @@
|
||||
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */,
|
||||
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */,
|
||||
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */,
|
||||
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */,
|
||||
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */,
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */,
|
||||
9806E8182BC5D12500757370 /* App.swift in Sources */,
|
||||
|
||||
@@ -9,13 +9,36 @@ import SwiftUI
|
||||
@main
|
||||
struct AudioPlayerApp: App {
|
||||
|
||||
@State var model = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class AppModel {
|
||||
@ObservationIgnored
|
||||
let audioPlayerService: AudioPlayerService
|
||||
@ObservationIgnored
|
||||
let equalizerService: EqualizerService
|
||||
|
||||
init(
|
||||
audioPlayerService: AudioPlayerService = provideAudioPlayerService(),
|
||||
equalizerService: (AudioPlayerService) -> EqualizerService = provideEqualizerService
|
||||
) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
self.equalizerService = equalizerService(audioPlayerService)
|
||||
}
|
||||
}
|
||||
|
||||
func provideEqualizerService(playerService: AudioPlayerService) -> EqualizerService {
|
||||
EqualizerService(playerService: playerService)
|
||||
}
|
||||
|
||||
func provideAudioPlayerService() -> AudioPlayerService {
|
||||
AudioPlayerService(
|
||||
audioPlayer: provideDefaultAudioPlayer()
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AudioContent: Int, CaseIterable {
|
||||
enum AudioContent {
|
||||
case offradio
|
||||
case enlefko
|
||||
case pepper966
|
||||
case kosmos
|
||||
case kosmosJazz
|
||||
case radiox
|
||||
case khruangbin
|
||||
case piano
|
||||
@@ -20,6 +21,7 @@ enum AudioContent: Int, CaseIterable {
|
||||
case remoteWave
|
||||
case local
|
||||
case localWave
|
||||
case custom(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -31,6 +33,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Pepper 96.6"
|
||||
case .kosmos:
|
||||
return "Kosmos 93.6"
|
||||
case .kosmosJazz:
|
||||
return "Kosmos Jazz"
|
||||
case .radiox:
|
||||
return "Radio X"
|
||||
case .khruangbin:
|
||||
@@ -47,21 +51,25 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Jazzy Frenchy"
|
||||
case .nonOptimized:
|
||||
return "Jazzy Frenchy"
|
||||
case .custom(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
switch self {
|
||||
case .offradio:
|
||||
return "Stream"
|
||||
return "Stream • offradio.gr"
|
||||
case .enlefko:
|
||||
return "Stream"
|
||||
return "Stream • enlefko.fm"
|
||||
case .pepper966:
|
||||
return "Stream"
|
||||
return "Stream • pepper966.gr"
|
||||
case .kosmos:
|
||||
return "Stream"
|
||||
return "Stream • ertecho.gr"
|
||||
case .kosmosJazz:
|
||||
return "Stream • ertecho.gr"
|
||||
case .radiox:
|
||||
return "Stream"
|
||||
return "Stream • globalplayer.com"
|
||||
case .khruangbin:
|
||||
return "Remote mp3"
|
||||
case .piano:
|
||||
@@ -76,6 +84,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Music by: bensound.com - m4a optimized"
|
||||
case .nonOptimized:
|
||||
return "Music by: bensound.com - m4a non-optimized"
|
||||
case .custom:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +99,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")!
|
||||
case .kosmos:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
|
||||
case .kosmosJazz:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-webjazz")!
|
||||
case .radiox:
|
||||
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
|
||||
case .khruangbin:
|
||||
@@ -107,6 +119,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return URL(fileURLWithPath: path)
|
||||
case .remoteWave:
|
||||
return URL(string: "https://file-examples.com/wp-content/storage/2017/11/file_example_WAV_5MG.wav")!
|
||||
case .custom(let url):
|
||||
return URL(string: url)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ struct AudioTrackView: View {
|
||||
Text(track.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.black)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, track.subtitle == nil ? 8 : 0)
|
||||
if let subtitle = track.subtitle {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// AddNewAudioURLView.swift
|
||||
// AudioPlayer
|
||||
//
|
||||
// Created by Dimitris Chatzieleftheriou on 16/04/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddNewAudioURLView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let urlStyle = URL.FormatStyle(path: .omitWhen(.path, matches: ["/"]), query: .omitWhen(.query, matches: [""]))
|
||||
|
||||
@State private var audioUrl: URL?
|
||||
|
||||
var onAddNewUrl: (URL) -> Void
|
||||
|
||||
init(onAddNewUrl: @escaping (URL) -> Void) {
|
||||
self.onAddNewUrl = onAddNewUrl
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
VStack(alignment: .leading) {
|
||||
TextField(value: $audioUrl, format: urlStyle, prompt: nil, label: {
|
||||
Text("Insert URL")
|
||||
})
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
if let url = audioUrl {
|
||||
onAddNewUrl(url)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
Button {
|
||||
if let url = audioUrl {
|
||||
onAddNewUrl(url)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus")
|
||||
Text("Add")
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.disabled(audioUrl == nil)
|
||||
.opacity(audioUrl == nil ? 0.5 : 1.0)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.mint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.navigationTitle("Add Audio URL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddNewAudioURLView(onAddNewUrl: { _ in })
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import UIKit
|
||||
import Foundation
|
||||
import AudioStreaming
|
||||
|
||||
struct AudioPlaylist: Identifiable {
|
||||
struct AudioPlaylist: Equatable, Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let tracks: [AudioTrack]
|
||||
var tracks: [AudioTrack]
|
||||
}
|
||||
|
||||
enum ScrubState: Equatable {
|
||||
@@ -22,7 +22,7 @@ enum ScrubState: Equatable {
|
||||
@Observable
|
||||
public class AudioPlayerModel {
|
||||
@ObservationIgnored
|
||||
var audioPlayerService: AudioPlayerService
|
||||
private(set) var audioPlayerService: AudioPlayerService
|
||||
@ObservationIgnored
|
||||
private var displayLink: DisplayLink?
|
||||
|
||||
@@ -49,8 +49,8 @@ public class AudioPlayerModel {
|
||||
|
||||
var currentTrack: AudioTrack?
|
||||
|
||||
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: () -> AudioPlayerService = provideAudioPlayerService) {
|
||||
self.audioPlayerService = audioPlayerService()
|
||||
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
self.audioTracks = audioTracksProvider()
|
||||
|
||||
self.audioPlayerService.delegate = self
|
||||
@@ -62,6 +62,20 @@ public class AudioPlayerModel {
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
func addNewAudioTrack(url: URL) {
|
||||
let customIndex = audioTracks.firstIndex(where: { $0.id == "Custom" })
|
||||
let audioTrack = AudioTrack(from: .custom(url.absoluteString), status: .idle)
|
||||
let playlist = AudioPlaylist(title: "Custom", tracks: [audioTrack])
|
||||
if let customIndex {
|
||||
let tracks = audioTracks[customIndex].tracks
|
||||
if !tracks.contains(audioTrack) {
|
||||
audioTracks[customIndex].tracks.append(audioTrack)
|
||||
}
|
||||
} else {
|
||||
audioTracks.append(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
func mute() {
|
||||
isMuted.toggle()
|
||||
audioPlayerService.toggleMute()
|
||||
@@ -187,7 +201,7 @@ extension AudioPlayerModel: AudioPlayerServiceDelegate {
|
||||
func metadataReceived(metadata: [String : String]) {
|
||||
guard !metadata.isEmpty else { return }
|
||||
if let title = metadata["StreamTitle"] {
|
||||
liveAudioMetadata = title
|
||||
liveAudioMetadata = title.isEmpty ? "-" : title
|
||||
} else {
|
||||
liveAudioMetadata = nil
|
||||
}
|
||||
@@ -195,7 +209,7 @@ extension AudioPlayerModel: AudioPlayerServiceDelegate {
|
||||
}
|
||||
|
||||
func audioTracksProvider() -> [AudioPlaylist] {
|
||||
let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .radiox]
|
||||
let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox]
|
||||
let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave]
|
||||
|
||||
return [
|
||||
|
||||
@@ -6,29 +6,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AudioPlayerView: View {
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
@State var model = AudioPlayerModel()
|
||||
@State var model: AudioPlayerModel
|
||||
|
||||
@State var eqSheetIsShown: Bool = false
|
||||
@State var addNewAudioIsShown: Bool = false
|
||||
|
||||
init(appModel: AppModel) {
|
||||
self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(model.audioTracks) { section in
|
||||
Section {
|
||||
ForEach(section.tracks) { track in
|
||||
AudioTrackView(track: track) {
|
||||
model.play(track)
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(model.audioTracks) { section in
|
||||
Section {
|
||||
ForEach(section.tracks) { track in
|
||||
AudioTrackView(track: track) {
|
||||
model.play(track)
|
||||
}
|
||||
.id(track.id)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: model.audioTracks) { _, newValue in
|
||||
if let lastId = newValue.last?.tracks.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
AudioPlayerControls(viewModel: model)
|
||||
.background(Color.mint)
|
||||
.background(
|
||||
.ultraThinMaterial.shadow(
|
||||
ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10)
|
||||
)
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.navigationTitle("Audio Player")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
eqSheetIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
Button {
|
||||
addNewAudioIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $eqSheetIsShown) {
|
||||
EqualizerView(appModel: appModel)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.sheet(isPresented: $addNewAudioIsShown) {
|
||||
AddNewAudioURLView(
|
||||
onAddNewUrl: { url in
|
||||
model.addNewAudioTrack(url: url)
|
||||
}
|
||||
)
|
||||
.presentationDetents([.height(150)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +108,12 @@ struct AudioPlayerControls: View {
|
||||
.frame(width: 20, height: 20)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.tint(.white)
|
||||
.tint(.mint)
|
||||
.padding(16)
|
||||
if let audioMetadata = viewModel.liveAudioMetadata, viewModel.isLiveAudioStreaming {
|
||||
Text("Now Playing: \(audioMetadata)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
Divider()
|
||||
@@ -86,7 +135,7 @@ struct AudioPlayerControls: View {
|
||||
Spacer()
|
||||
Text(viewModel.formattedTotalTime ?? "")
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.black)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
@@ -97,7 +146,7 @@ struct AudioPlayerControls: View {
|
||||
Text("Playback Rate: \(String(format: "%0.1f", viewModel.playbackRate))")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.black)
|
||||
Slider(value: $viewModel.playbackRate, in: 1.0...4.0, step: 0.2)
|
||||
.onChange(of: viewModel.playbackRate) { _, new in
|
||||
viewModel.update(rate: Float(new))
|
||||
@@ -110,5 +159,5 @@ struct AudioPlayerControls: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AudioPlayerView(model: AudioPlayerModel())
|
||||
AudioPlayerView(appModel: AppModel())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// Created by Dimitris Chatzieleftheriou on 15/04/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class EQBand: Identifiable {
|
||||
var frequency: String
|
||||
var min: Float
|
||||
var max: Float
|
||||
var value: Float
|
||||
|
||||
@ObservationIgnored
|
||||
let index: Int
|
||||
|
||||
init(index: Int, frequency: String, min: Float, max: Float, value: Float) {
|
||||
self.index = index
|
||||
self.frequency = frequency
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
struct EqualizerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
@State var model: Model
|
||||
|
||||
init(appModel: AppModel) {
|
||||
self._model = State(wrappedValue: Model(equalizerService: appModel.equalizerService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Toggle(isOn: $model.isEnabled) {
|
||||
Text("Enable")
|
||||
}
|
||||
.onChange(of: model.isEnabled) { _, _ in
|
||||
model.enable()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
VStack {
|
||||
EQSliderView()
|
||||
.frame(height: 180)
|
||||
.padding(.horizontal, 16)
|
||||
.environment(model)
|
||||
Button {
|
||||
withAnimation {
|
||||
model.reset()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Reset")
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.mint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
Task {
|
||||
model.generateBands()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Equalizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EQSliderView: View {
|
||||
@Environment(EqualizerView.Model.self) var eqModel
|
||||
|
||||
@State private var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
|
||||
|
||||
@State private var eqViewFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
// Draw labels for gain values
|
||||
VStack {
|
||||
Text("\(Int(eqModel.maxGain))db")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
Spacer()
|
||||
Text("0dB")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
Spacer()
|
||||
Text("\(Int(eqModel.minGain))db")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
GeometryReader { innerGeo in
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: innerGeo.size.width / 12, y: eqModel.dragPoints.first ?? 0))
|
||||
for index in 1..<eqModel.dragPoints.count {
|
||||
let x = positionForDragPoint(at: index, size: innerGeo.size)
|
||||
let y = eqModel.dragPoints[index]
|
||||
path.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
}
|
||||
.stroke(Color.mint, lineWidth: 2)
|
||||
.animation(.default, value: eqModel.dragPoints)
|
||||
|
||||
Path { path in
|
||||
for index in 0..<eqModel.dragPoints.count {
|
||||
let x = positionForDragPoint(at: index, size: innerGeo.size)
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x, y: innerGeo.size.height))
|
||||
}
|
||||
path.move(to: CGPoint(x: 0, y: innerGeo.size.height / 2))
|
||||
path.addLine(to: CGPoint(x: innerGeo.size.width, y: innerGeo.size.height / 2))
|
||||
}
|
||||
.stroke(Color.gray, lineWidth: 1)
|
||||
|
||||
ForEach(eqModel.bands) { band in
|
||||
Circle()
|
||||
.fill(Color.mint)
|
||||
.frame(width: 20, height: 20)
|
||||
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: eqModel.dragPoints[band.index])
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let newY = min(max(value.location.y, 0), innerGeo.size.height)
|
||||
eqModel.dragPoints[band.index] = newY
|
||||
updateGainValue(at: band.index, in: innerGeo.size)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
eqModel.dragPoints[band.index] = gainToYPosition(at: band.value, in: innerGeo.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(eqModel.bands) { band in
|
||||
Text(band.frequency)
|
||||
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: innerGeo.size.height)
|
||||
.font(.caption)
|
||||
.foregroundColor(.black)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
|
||||
size.width / 12 * CGFloat(index * 2 + 1)
|
||||
}
|
||||
|
||||
func updateGainValue(at index: Int, in size: CGSize) {
|
||||
let percentage = dragPointYLocations[index] / size.height
|
||||
let gain = (1 - Float(percentage)) * (eqModel.maxGain - eqModel.minGain) + eqModel.minGain
|
||||
eqModel.update(gain: gain, index: index)
|
||||
}
|
||||
|
||||
func gainToYPosition(at gain: Float, in size: CGSize) -> CGFloat {
|
||||
let percentage = 1 - (gain - eqModel.minGain) / (eqModel.maxGain - eqModel.minGain)
|
||||
return CGFloat(percentage) * size.height
|
||||
}
|
||||
}
|
||||
|
||||
extension EqualizerView {
|
||||
@Observable
|
||||
class Model {
|
||||
@ObservationIgnored
|
||||
private let equalizerService: EqualizerService
|
||||
|
||||
var isEnabled: Bool = false
|
||||
|
||||
var bands: [EQBand] = []
|
||||
|
||||
var dragPoints: [CGFloat] = []
|
||||
|
||||
let minGain: Float = -12
|
||||
let maxGain: Float = 12
|
||||
|
||||
init(equalizerService: EqualizerService) {
|
||||
self.equalizerService = equalizerService
|
||||
dragPoints = [CGFloat].init(repeating: 0, count: equalizerService.bands.count)
|
||||
}
|
||||
|
||||
func generateBands() {
|
||||
bands = equalizerService.bands.enumerated().map { index, item in
|
||||
var measurement = item.frequency
|
||||
var frequency = String(Int(measurement))
|
||||
if item.frequency >= 1000 {
|
||||
measurement = item.frequency / 1000
|
||||
frequency = "\(String(Int(measurement)))K"
|
||||
}
|
||||
return EQBand(index: index, frequency: frequency, min: minGain, max: maxGain, value: item.gain)
|
||||
}
|
||||
|
||||
dragPoints = [CGFloat].init(repeating: 0, count: bands.count)
|
||||
}
|
||||
|
||||
func enable() {
|
||||
if isEnabled {
|
||||
equalizerService.activate()
|
||||
} else {
|
||||
equalizerService.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
func update(gain: Float, index: Int) {
|
||||
equalizerService.update(gain: gain, for: index)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
equalizerService.reset()
|
||||
dragPoints = [CGFloat].init(repeating: 0, count: bands.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EqualizerView(appModel: AppModel())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Created by Dimitris Chatzieleftheriou on 15/04/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
final class EqualizerService {
|
||||
private let playerService: AudioPlayerService
|
||||
private let _freqs = [60, 150, 400, 1000, 2400, 15000]
|
||||
private let eqUnit: AVAudioUnitEQ
|
||||
|
||||
var bands: [AVAudioUnitEQFilterParameters] {
|
||||
eqUnit.bands
|
||||
}
|
||||
|
||||
private(set) var isActivated: Bool = false
|
||||
|
||||
init(playerService: AudioPlayerService) {
|
||||
self.playerService = playerService
|
||||
|
||||
eqUnit = AVAudioUnitEQ(numberOfBands: _freqs.count)
|
||||
for i in 0 ..< _freqs.count {
|
||||
eqUnit.bands[i].bypass = false
|
||||
eqUnit.bands[i].filterType = .parametric
|
||||
eqUnit.bands[i].frequency = Float(_freqs[i])
|
||||
eqUnit.bands[i].bandwidth = 0.5
|
||||
eqUnit.bands[i].gain = 0
|
||||
}
|
||||
}
|
||||
|
||||
func update(gain: Float, for index: Int) {
|
||||
eqUnit.bands[index].gain = gain
|
||||
}
|
||||
|
||||
func reset() {
|
||||
eqUnit.bands.forEach { $0.gain = 0 }
|
||||
}
|
||||
|
||||
func activate() {
|
||||
isActivated = true
|
||||
playerService.add(eqUnit)
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
isActivated = false
|
||||
playerService.remove(eqUnit)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
@State private var selection: NavigationContent?
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DetailView: View {
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
var selection: NavigationContent
|
||||
|
||||
var body: some View {
|
||||
switch selection {
|
||||
case .audioPlayer:
|
||||
AudioPlayerView()
|
||||
AudioPlayerView(appModel: appModel)
|
||||
case .audioQueue:
|
||||
Text("Audio Queue")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user