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:
@@ -420,7 +420,6 @@ extension View {
|
||||
|
||||
enum AudioPanel: String, CaseIterable, Identifiable {
|
||||
case mixer = "Mixer"
|
||||
case equalizer = "EQ"
|
||||
case enhance = "FX"
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -428,7 +427,6 @@ enum AudioPanel: String, CaseIterable, Identifiable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .mixer: "slider.vertical.3"
|
||||
case .equalizer: "waveform.path.ecg"
|
||||
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 index: Int
|
||||
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 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 {
|
||||
case .mixer:
|
||||
mixerPanel(vm)
|
||||
case .equalizer:
|
||||
equalizerPanel(vm)
|
||||
case .enhance:
|
||||
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
|
||||
private func enhancePanel(_ vm: AudioViewModel) -> some View {
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
|
||||
@@ -196,9 +196,9 @@ struct HomeView: View {
|
||||
quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
NavigationLink {
|
||||
AudioView()
|
||||
AudioProfilesView()
|
||||
} label: {
|
||||
quickLinkLabel("Audio", icon: "slider.vertical.3")
|
||||
quickLinkLabel("Profilo", icon: "waveform.path.ecg")
|
||||
}
|
||||
NavigationLink {
|
||||
StreamingView()
|
||||
|
||||
@@ -36,8 +36,8 @@ private struct IPhoneRootView: View {
|
||||
NavigationStack { PresetsView() }
|
||||
.tabItem { Label("Preset", systemImage: "star.fill") }
|
||||
|
||||
NavigationStack { AudioView() }
|
||||
.tabItem { Label("Audio", systemImage: "waveform") }
|
||||
NavigationStack { AudioProfilesView() }
|
||||
.tabItem { Label("Profilo", systemImage: "waveform.path.ecg") }
|
||||
|
||||
NavigationStack { SettingsRootView() }
|
||||
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
|
||||
@@ -63,7 +63,7 @@ private struct IPadRootView: View {
|
||||
case .dab: DABRadioView()
|
||||
case .stream: StreamingView()
|
||||
case .bluetooth: BluetoothView()
|
||||
case .audio: AudioView()
|
||||
case .audioProfiles: AudioProfilesView()
|
||||
case .presets: PresetsView()
|
||||
case .settings: SettingsRootView()
|
||||
}
|
||||
@@ -72,7 +72,7 @@ private struct IPadRootView: View {
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -83,7 +83,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case .dab: "DAB"
|
||||
case .stream: "Stream"
|
||||
case .bluetooth: "Bluetooth"
|
||||
case .audio: "Audio"
|
||||
case .audioProfiles: "Profilo audio"
|
||||
case .presets: "Preset"
|
||||
case .settings: "Impostazioni"
|
||||
}
|
||||
@@ -96,7 +96,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case .dab: "antenna.radiowaves.left.and.right"
|
||||
case .stream: "dot.radiowaves.forward"
|
||||
case .bluetooth: "dot.radiowaves.left.and.right"
|
||||
case .audio: "waveform"
|
||||
case .audioProfiles: "waveform.path.ecg"
|
||||
case .presets: "star.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@ struct SettingsRootView: View {
|
||||
NavigationLink("Preset") { PresetsView() }
|
||||
}
|
||||
Section("Audio") {
|
||||
NavigationLink {
|
||||
AudioProfilesView()
|
||||
} label: {
|
||||
Label("Profili audio", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
NavigationLink {
|
||||
AudioView()
|
||||
} label: {
|
||||
Label("Mixer & Equalizzatore", systemImage: "slider.vertical.3")
|
||||
Label("Mixer & volume", systemImage: "slider.vertical.3")
|
||||
}
|
||||
NavigationLink("Bluetooth speaker") { BluetoothView() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user