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 {
|
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() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user