Add audio profile presets tab with user-saved profiles and EQ editor.

Replace separate Audio/EQ tabs with a unified profile picker (built-in + local presets), keeping mixer access and applying full profiles to the device.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 17:06:59 +02:00
co-authored by Cursor
parent a1aaefd47a
commit 9ad2e42fe2
12 changed files with 564 additions and 39 deletions
@@ -420,7 +420,6 @@ extension View {
enum AudioPanel: String, CaseIterable, Identifiable { enum AudioPanel: String, CaseIterable, Identifiable {
case mixer = "Mixer" case mixer = "Mixer"
case equalizer = "EQ"
case enhance = "FX" case enhance = "FX"
var id: String { rawValue } var id: String { rawValue }
@@ -428,7 +427,6 @@ enum AudioPanel: String, CaseIterable, Identifiable {
var icon: String { var icon: String {
switch self { switch self {
case .mixer: "slider.vertical.3" case .mixer: "slider.vertical.3"
case .equalizer: "waveform.path.ecg"
case .enhance: "sparkles" case .enhance: "sparkles"
} }
} }
@@ -0,0 +1,75 @@
import Foundation
/// Audio profile: EQ + enhancements (stored on device via PUT /api/audio/profile).
struct AudioProfileTemplate: Identifiable, Codable, Equatable, Hashable {
var id: String
var name: String
var subtitle: String
var systemImage: String
var isBuiltIn: Bool
var eq: [EQBandState]
var enhancements: EnhancementsState
static let bandCenters: [Double] = [40, 120, 400, 1000, 3000, 10_000]
static func eqBands(gains: [Double]) -> [EQBandState] {
zip(bandCenters, gains).enumerated().map { index, pair in
EQBandState(index: index, gainDb: pair.1, centerHz: pair.0, q: 1.414)
}
}
func profileDTO(mixer: MixerState, master: MasterVolumeState) -> AudioProfileDTO {
AudioProfileDTO(mixer: mixer, master: master, eq: eq, enhancements: enhancements)
}
func duplicatedAsUser(named name: String) -> AudioProfileTemplate {
AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Profilo personalizzato",
systemImage: "slider.horizontal.3",
isBuiltIn: false,
eq: eq,
enhancements: enhancements
)
}
}
enum AudioProfileLibrary {
static let builtIn: [AudioProfileTemplate] = [
template(id: "builtin.flat", name: "Piatto", subtitle: "Risposta neutra", icon: "minus", gains: [0, 0, 0, 0, 0, 0], stereo: 0, bass: 0),
template(id: "builtin.vocal", name: "Voci", subtitle: "Parlato e podcast", icon: "person.wave.2", gains: [-2, -1, 3, 4, 2, 0], stereo: 15, bass: 5),
template(id: "builtin.bass", name: "Bassi", subtitle: "Più corpo e profondità", icon: "waveform.path", gains: [6, 4, 1, 0, -1, -2], stereo: 10, bass: 35),
template(id: "builtin.treble", name: "Alti", subtitle: "Dettaglio e aria", icon: "sparkles", gains: [-2, -1, 0, 2, 4, 5], stereo: 25, bass: 0),
template(id: "builtin.rock", name: "Rock", subtitle: "Energia V-shape", icon: "guitars", gains: [5, 3, -1, 1, 4, 5], stereo: 30, bass: 25),
template(id: "builtin.jazz", name: "Jazz", subtitle: "Caldo e naturale", icon: "music.quarternote.3", gains: [3, 2, 1, 2, 1, 0], stereo: 20, bass: 15),
template(id: "builtin.classical", name: "Classica", subtitle: "Equilibrio dinamico", icon: "hifispeaker.2", gains: [2, 1, 0, 0, 2, 3], stereo: 15, bass: 5),
template(id: "builtin.lounge", name: "Lounge", subtitle: "Ascolto rilassato", icon: "moon.stars", gains: [2, 1, 0, -1, -2, -3], stereo: 10, bass: 20),
template(id: "builtin.fm", name: "Radio FM", subtitle: "Voce in evidenza", icon: "radio", gains: [-1, 0, 2, 3, 2, 1], stereo: 20, bass: 10)
]
private static func template(
id: String, name: String, subtitle: String, icon: String,
gains: [Double], stereo: Int, bass: Int
) -> AudioProfileTemplate {
AudioProfileTemplate(
id: id,
name: name,
subtitle: subtitle,
systemImage: icon,
isBuiltIn: true,
eq: AudioProfileTemplate.eqBands(gains: gains),
enhancements: EnhancementsState(stereoLevel: stereo, bassLevel: bass)
)
}
static func matchActive(_ profile: AudioProfileTemplate, eq: [EQBandState], enhancements: EnhancementsState) -> Bool {
guard eq.count == profile.eq.count else { return false }
let sortedEq = eq.sorted { $0.centerHz < $1.centerHz }
let sortedProfile = profile.eq.sorted { $0.centerHz < $1.centerHz }
let eqMatch = zip(sortedEq, sortedProfile).allSatisfy { abs($0.gainDb - $1.gainDb) < 0.6 }
return eqMatch
&& profile.enhancements.stereoLevel == enhancements.stereoLevel
&& profile.enhancements.bassLevel == enhancements.bassLevel
}
}
@@ -116,7 +116,7 @@ struct MasterVolumeState: Equatable, Sendable, Codable {
} }
} }
struct EQBandState: Equatable, Sendable, Codable, Identifiable { struct EQBandState: Equatable, Hashable, Sendable, Codable, Identifiable {
var id: Int { index } var id: Int { index }
var index: Int var index: Int
var gainDb: Double var gainDb: Double
@@ -152,7 +152,7 @@ struct EQBandState: Equatable, Sendable, Codable, Identifiable {
} }
} }
struct EnhancementsState: Equatable, Sendable, Codable { struct EnhancementsState: Equatable, Hashable, Sendable, Codable {
var stereoLevel: Int = 0 var stereoLevel: Int = 0
var bassLevel: Int = 0 var bassLevel: Int = 0
@@ -0,0 +1,20 @@
import Foundation
/// Persists user-created audio profiles on the iPhone (firmware stores one active profile only).
enum AudioProfileStore {
private static let key = "igiRadio.userAudioProfiles"
static func loadUserProfiles() -> [AudioProfileTemplate] {
guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
return (try? JSONDecoder().decode([AudioProfileTemplate].self, from: data)) ?? []
}
static func saveUserProfiles(_ profiles: [AudioProfileTemplate]) {
guard let data = try? JSONEncoder().encode(profiles) else { return }
UserDefaults.standard.set(data, forKey: key)
}
static func allProfiles() -> [AudioProfileTemplate] {
AudioProfileLibrary.builtIn + loadUserProfiles()
}
}
@@ -0,0 +1,51 @@
import Foundation
import Observation
@Observable
final class AudioProfileEditorViewModel {
var profile: AudioProfileTemplate
private let service: any DigiRadioService
private let onSave: (AudioProfileTemplate) -> Void
private var applyTask: Task<Void, Never>?
var isSaving = false
var isNewProfile: Bool
init(
profile: AudioProfileTemplate,
service: any DigiRadioService,
isNewProfile: Bool,
onSave: @escaping (AudioProfileTemplate) -> Void
) {
self.profile = profile
self.service = service
self.isNewProfile = isNewProfile
self.onSave = onSave
}
func schedulePreviewApply() {
guard !profile.isBuiltIn else { return }
applyTask?.cancel()
applyTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await applyToDevice(persistUserCopy: false)
}
}
func applyToDevice(persistUserCopy: Bool) async {
isSaving = true
defer { isSaving = false }
let audio = service.state.audio
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
try? await service.applyAudioProfile(dto)
if persistUserCopy, !profile.isBuiltIn {
onSave(profile)
}
}
func saveUserProfile() {
guard !profile.isBuiltIn else { return }
onSave(profile)
}
}
@@ -0,0 +1,94 @@
import Foundation
import Observation
@Observable
final class AudioProfilesViewModel {
private let service: any DigiRadioService
var builtIn: [AudioProfileTemplate] = AudioProfileLibrary.builtIn
var userProfiles: [AudioProfileTemplate] = []
var activeProfileID: String?
var isLoading = false
var isApplying = false
var errorMessage: String?
init(service: any DigiRadioService) {
self.service = service
reloadUserProfiles()
}
func reloadUserProfiles() {
userProfiles = AudioProfileStore.loadUserProfiles()
}
func load() async {
isLoading = true
defer { isLoading = false }
try? await service.refreshAudioProfile()
detectActiveProfile()
}
func detectActiveProfile() {
let audio = service.state.audio
activeProfileID = AudioProfileStore.allProfiles().first {
AudioProfileLibrary.matchActive($0, eq: audio.eq, enhancements: audio.enhancements)
}?.id
}
func apply(_ profile: AudioProfileTemplate) async {
isApplying = true
errorMessage = nil
defer { isApplying = false }
do {
let audio = service.state.audio
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
try await service.applyAudioProfile(dto)
activeProfileID = profile.id
} catch {
errorMessage = error.localizedDescription
}
}
func deleteUserProfile(_ profile: AudioProfileTemplate) {
guard !profile.isBuiltIn else { return }
userProfiles.removeAll { $0.id == profile.id }
AudioProfileStore.saveUserProfiles(userProfiles)
if activeProfileID == profile.id { activeProfileID = nil }
}
func saveUserProfile(_ profile: AudioProfileTemplate) {
var copy = profile
copy.isBuiltIn = false
if let index = userProfiles.firstIndex(where: { $0.id == copy.id }) {
userProfiles[index] = copy
} else {
userProfiles.append(copy)
}
AudioProfileStore.saveUserProfiles(userProfiles)
}
func duplicateAsUser(from profile: AudioProfileTemplate, name: String) -> AudioProfileTemplate {
AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Profilo personalizzato",
systemImage: "slider.horizontal.3",
isBuiltIn: false,
eq: profile.eq,
enhancements: profile.enhancements
)
}
func profileFromDevice(name: String) -> AudioProfileTemplate {
let audio = service.state.audio
return AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Creato dal dispositivo",
systemImage: "waveform.path.ecg",
isBuiltIn: false,
eq: audio.eq.isEmpty ? AudioProfileLibrary.builtIn[0].eq : audio.eq,
enhancements: audio.enhancements
)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
struct AudioProfileEditorView: View {
@Environment(AppEnvironment.self) private var environment
@Environment(\.dismiss) private var dismiss
@State private var viewModel: AudioProfileEditorViewModel?
let profile: AudioProfileTemplate
let isNewProfile: Bool
let onSave: (AudioProfileTemplate) -> Void
var body: some View {
let vm = viewModel ?? AudioProfileEditorViewModel(
profile: profile,
service: environment.digiRadio,
isNewProfile: isNewProfile,
onSave: onSave
)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
if !vm.profile.isBuiltIn {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
Text("Nome profilo")
.font(.headline)
TextField("Nome", text: Binding(
get: { vm.profile.name },
set: { vm.profile.name = $0 }
))
.padding()
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
}
.igiPremiumCard()
}
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Equalizzatore")
.font(.title3.weight(.bold))
IGIGraphicEqualizer(
bands: Binding(
get: { vm.profile.eq },
set: { vm.profile.eq = $0 }
),
onCommit: { vm.schedulePreviewApply() }
)
}
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Enhancements")
.font(.headline)
HStack(spacing: IGITheme.spacingL) {
IGIEnhancementDial(
title: "Stereo",
systemImage: "circle.lefthalf.filled",
level: Binding(
get: { Double(vm.profile.enhancements.stereoLevel) },
set: { vm.profile.enhancements.stereoLevel = Int($0) }
),
onCommit: { vm.schedulePreviewApply() }
)
IGIEnhancementDial(
title: "Bass",
systemImage: "waveform.path",
level: Binding(
get: { Double(vm.profile.enhancements.bassLevel) },
set: { vm.profile.enhancements.bassLevel = Int($0) }
),
onCommit: { vm.schedulePreviewApply() }
)
}
}
.igiPremiumCard()
if !vm.profile.isBuiltIn {
Button {
vm.saveUserProfile()
Task { await vm.applyToDevice(persistUserCopy: true) }
dismiss()
} label: {
Label("Salva profilo", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
Button {
Task { await vm.applyToDevice(persistUserCopy: false) }
} label: {
Label("Applica a DigiRadio", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
if vm.profile.isBuiltIn {
Button {
let duplicate = vm.profile.duplicatedAsUser(named: "\(vm.profile.name) custom")
onSave(duplicate)
dismiss()
} label: {
Label("Salva come profilo personale", systemImage: "plus.square.on.square")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
}
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle(vm.profile.isBuiltIn ? vm.profile.name : "Modifica profilo")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if viewModel == nil { viewModel = vm }
}
}
}
@@ -0,0 +1,190 @@
import SwiftUI
struct AudioProfilesView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: AudioProfilesViewModel?
@State private var editorProfile: AudioProfileTemplate?
@State private var editorIsNew = false
@State private var showNewProfileSheet = false
@State private var newProfileName = ""
var body: some View {
let vm = viewModel ?? AudioProfilesViewModel(service: environment.digiRadio)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
activeBanner(vm)
profileSection(title: "Predefiniti", profiles: vm.builtIn, vm: vm, allowDelete: false, onDelete: nil)
profileSection(title: "I tuoi profili", profiles: vm.userProfiles, vm: vm, allowDelete: true, onDelete: { vm.deleteUserProfile($0) })
if vm.userProfiles.isEmpty {
Text("Crea un profilo personalizzato partendo da un preset o dal suono attuale del device.")
.font(.caption)
.foregroundStyle(.secondary)
.igiPremiumCard()
}
HStack(spacing: IGITheme.spacingM) {
Button {
newProfileName = "Il mio profilo"
showNewProfileSheet = true
} label: {
Label("Nuovo profilo", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
NavigationLink {
AudioView()
} label: {
Label("Mixer", systemImage: "slider.vertical.3")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
}
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle("Profilo audio")
.navigationBarTitleDisplayMode(.large)
.navigationDestination(item: $editorProfile) { profile in
AudioProfileEditorView(
profile: profile,
isNewProfile: editorIsNew,
onSave: { saved in
vm.saveUserProfile(saved)
vm.reloadUserProfiles()
vm.activeProfileID = saved.id
}
)
}
.alert("Nuovo profilo", isPresented: $showNewProfileSheet) {
TextField("Nome", text: $newProfileName)
Button("Annulla", role: .cancel) {}
Button("Crea") {
let profile = vm.profileFromDevice(name: newProfileName.isEmpty ? "Il mio profilo" : newProfileName)
editorIsNew = true
editorProfile = profile
}
} message: {
Text("Parte dal profilo attualmente sul DigiRadio.")
}
.onAppear {
if viewModel == nil { viewModel = vm }
Task { await vm.load() }
}
}
@ViewBuilder
private func activeBanner(_ vm: AudioProfilesViewModel) -> some View {
HStack(spacing: IGITheme.spacingM) {
Image(systemName: "checkmark.seal.fill")
.font(.title2)
.foregroundStyle(IGITheme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Profilo attivo")
.font(.caption)
.foregroundStyle(.secondary)
Text(vm.builtIn.first(where: { $0.id == vm.activeProfileID })?.name
?? vm.userProfiles.first(where: { $0.id == vm.activeProfileID })?.name
?? "Personalizzato / device")
.font(.headline)
}
Spacer()
if vm.isApplying { ProgressView() }
}
.igiPremiumCard()
}
@ViewBuilder
private func profileSection(
title: String,
profiles: [AudioProfileTemplate],
vm: AudioProfilesViewModel,
allowDelete: Bool,
onDelete: ((AudioProfileTemplate) -> Void)?
) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text(title)
.font(.title3.weight(.bold))
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: IGITheme.spacingS)], spacing: IGITheme.spacingS) {
ForEach(profiles) { profile in
AudioProfileCard(
profile: profile,
isActive: vm.activeProfileID == profile.id,
allowDelete: allowDelete
) {
Task { await vm.apply(profile) }
} onEdit: {
editorIsNew = false
editorProfile = profile
} onDelete: {
onDelete?(profile)
}
}
}
}
.igiPremiumCard()
}
}
private struct AudioProfileCard: View {
var profile: AudioProfileTemplate
var isActive: Bool
var allowDelete: Bool
var onApply: () -> Void
var onEdit: () -> Void
var onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
HStack {
Image(systemName: profile.systemImage)
.foregroundStyle(isActive ? .white : IGITheme.accent)
Spacer()
if isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white)
}
}
Text(profile.name)
.font(.headline)
.foregroundStyle(isActive ? .white : .primary)
Text(profile.subtitle)
.font(.caption2)
.foregroundStyle(isActive ? .white.opacity(0.85) : .secondary)
.lineLimit(2)
HStack(spacing: IGITheme.spacingS) {
Button("Applica", action: onApply)
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(isActive ? .white : IGITheme.accent)
Button("Modifica", action: onEdit)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.tint(isActive ? .white : .primary)
}
.padding(.top, 4)
}
.padding(IGITheme.spacingM)
.background(
isActive
? AnyShapeStyle(IGITheme.accent.gradient)
: AnyShapeStyle(Color.primary.opacity(0.05)),
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
.contextMenu {
if allowDelete {
Button(role: .destructive, action: onDelete) {
Label("Elimina", systemImage: "trash")
}
}
}
}
}
@@ -33,8 +33,6 @@ struct AudioView: View {
switch vm.panel { switch vm.panel {
case .mixer: case .mixer:
mixerPanel(vm) mixerPanel(vm)
case .equalizer:
equalizerPanel(vm)
case .enhance: case .enhance:
enhancePanel(vm) enhancePanel(vm)
} }
@@ -161,30 +159,6 @@ struct AudioView: View {
} }
} }
@ViewBuilder
private func equalizerPanel(_ vm: AudioViewModel) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Equalizzatore 6 bande")
.font(.headline)
Text("Trascina le barre per regolare il guadagno. Rilascia per applicare al DSP.")
.font(.caption)
.foregroundStyle(.secondary)
if vm.eqBands.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else {
IGIGraphicEqualizer(
bands: Binding(
get: { vm.eqBands },
set: { vm.eqBands = $0 }
),
onCommit: { vm.scheduleApply() }
)
}
}
}
@ViewBuilder @ViewBuilder
private func enhancePanel(_ vm: AudioViewModel) -> some View { private func enhancePanel(_ vm: AudioViewModel) -> some View {
HStack(spacing: IGITheme.spacingL) { HStack(spacing: IGITheme.spacingL) {
@@ -196,9 +196,9 @@ struct HomeView: View {
quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right") quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right")
} }
NavigationLink { NavigationLink {
AudioView() AudioProfilesView()
} label: { } label: {
quickLinkLabel("Audio", icon: "slider.vertical.3") quickLinkLabel("Profilo", icon: "waveform.path.ecg")
} }
NavigationLink { NavigationLink {
StreamingView() StreamingView()
@@ -36,8 +36,8 @@ private struct IPhoneRootView: View {
NavigationStack { PresetsView() } NavigationStack { PresetsView() }
.tabItem { Label("Preset", systemImage: "star.fill") } .tabItem { Label("Preset", systemImage: "star.fill") }
NavigationStack { AudioView() } NavigationStack { AudioProfilesView() }
.tabItem { Label("Audio", systemImage: "waveform") } .tabItem { Label("Profilo", systemImage: "waveform.path.ecg") }
NavigationStack { SettingsRootView() } NavigationStack { SettingsRootView() }
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") } .tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
@@ -63,7 +63,7 @@ private struct IPadRootView: View {
case .dab: DABRadioView() case .dab: DABRadioView()
case .stream: StreamingView() case .stream: StreamingView()
case .bluetooth: BluetoothView() case .bluetooth: BluetoothView()
case .audio: AudioView() case .audioProfiles: AudioProfilesView()
case .presets: PresetsView() case .presets: PresetsView()
case .settings: SettingsRootView() case .settings: SettingsRootView()
} }
@@ -72,7 +72,7 @@ private struct IPadRootView: View {
} }
enum SidebarItem: String, CaseIterable, Identifiable { enum SidebarItem: String, CaseIterable, Identifiable {
case home, fm, dab, stream, bluetooth, audio, presets, settings case home, fm, dab, stream, bluetooth, audioProfiles, presets, settings
var id: String { rawValue } var id: String { rawValue }
@@ -83,7 +83,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .dab: "DAB" case .dab: "DAB"
case .stream: "Stream" case .stream: "Stream"
case .bluetooth: "Bluetooth" case .bluetooth: "Bluetooth"
case .audio: "Audio" case .audioProfiles: "Profilo audio"
case .presets: "Preset" case .presets: "Preset"
case .settings: "Impostazioni" case .settings: "Impostazioni"
} }
@@ -96,7 +96,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .dab: "antenna.radiowaves.left.and.right" case .dab: "antenna.radiowaves.left.and.right"
case .stream: "dot.radiowaves.forward" case .stream: "dot.radiowaves.forward"
case .bluetooth: "dot.radiowaves.left.and.right" case .bluetooth: "dot.radiowaves.left.and.right"
case .audio: "waveform" case .audioProfiles: "waveform.path.ecg"
case .presets: "star.fill" case .presets: "star.fill"
case .settings: "gearshape.fill" case .settings: "gearshape.fill"
} }
@@ -16,10 +16,15 @@ struct SettingsRootView: View {
NavigationLink("Preset") { PresetsView() } NavigationLink("Preset") { PresetsView() }
} }
Section("Audio") { Section("Audio") {
NavigationLink {
AudioProfilesView()
} label: {
Label("Profili audio", systemImage: "waveform.path.ecg")
}
NavigationLink { NavigationLink {
AudioView() AudioView()
} label: { } label: {
Label("Mixer & Equalizzatore", systemImage: "slider.vertical.3") Label("Mixer & volume", systemImage: "slider.vertical.3")
} }
NavigationLink("Bluetooth speaker") { BluetoothView() } NavigationLink("Bluetooth speaker") { BluetoothView() }
} }