import SwiftUI // MARK: - VoiceSelectionView // Sheet for selecting TTS voice. Loads voices from the API, plays sample audio, and // saves the selection back to user settings on confirm. // VoiceSelectionViewModel is defined in PlayerViews.swift (shared with the full player). struct VoiceSelectionView: View { @EnvironmentObject private var authStore: AuthStore @Environment(\.dismiss) private var dismiss @State private var selectedVoice: String @State private var vm = VoiceSelectionViewModel() init(currentVoice: String) { _selectedVoice = State(initialValue: currentVoice) } var body: some View { NavigationStack { Group { if vm.isLoading { loadingState } else if let error = vm.error { errorState(error) } else { voiceList } } .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationTitle("Select Voice") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { vm.stopSample() dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Done") { saveAndDismiss() } .fontWeight(.semibold) .foregroundStyle(Color.amber) .disabled(selectedVoice == authStore.settings.voice) } } .task { await vm.loadVoices() } .onDisappear { vm.stopSample() } } } // MARK: - States private var loadingState: some View { VStack(spacing: 16) { ProgressView() .scaleEffect(1.3) Text("Loading voices…") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func errorState(_ message: String) -> some View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 48)) .foregroundStyle(Color.amber) .symbolEffect(.pulse) Text(message) .font(.subheadline) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) Button("Retry") { Task { await vm.loadVoices() } } .font(.subheadline.bold()) .foregroundStyle(Color.amber) } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Voice list private var voiceList: some View { List { Section { ForEach(vm.voices, id: \.self) { voice in VoiceSelectionRow( voice: voice, isSelected: voice == selectedVoice, isPlaying: vm.playingVoice == voice, voiceLabel: vm.voiceLabel(voice), voiceId: vm.voiceId(voice), onSelect: { UIImpactFeedbackGenerator(style: .light).impactOccurred() vm.stopSample() selectedVoice = voice }, onPlaySample: { UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { await vm.playSample(voice) } } ) } } header: { Text("Available Voices") .font(.subheadline.bold()) .foregroundStyle(.secondary) .textCase(nil) } footer: { if selectedVoice != authStore.settings.voice { Text("New voice will apply to the next audio playback.") .font(.caption) .foregroundStyle(.secondary) } } } .scrollContentBackground(.hidden) .listStyle(.insetGrouped) } // MARK: - Save private func saveAndDismiss() { vm.stopSample() Task { var s = authStore.settings s.voice = selectedVoice await authStore.saveSettings(s) dismiss() } } } // MARK: - VoiceSelectionRow private struct VoiceSelectionRow: View { let voice: String let isSelected: Bool let isPlaying: Bool let voiceLabel: String let voiceId: String let onSelect: () -> Void let onPlaySample: () -> Void var body: some View { HStack(spacing: 12) { // Selection indicator Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.system(size: 22)) .foregroundStyle(isSelected ? Color.amber : Color.secondary.opacity(0.4)) .frame(width: 28) .contentTransition(.symbolEffect(.replace.downUp)) .accessibilityHidden(true) // Voice name + id VStack(alignment: .leading, spacing: 3) { Text(voiceLabel) .font(.body) .fontWeight(isSelected ? .semibold : .regular) Text(voiceId) .font(.caption) .fontDesign(.monospaced) .foregroundStyle(.secondary) } Spacer() // Play sample button Button { onPlaySample() } label: { Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") .font(.system(size: 28)) .foregroundStyle(isPlaying ? Color.red : Color.amber) .contentTransition(.symbolEffect(.replace.downUp)) } .buttonStyle(.plain) .frame(minWidth: 44, minHeight: 44) .accessibilityLabel(isPlaying ? "Stop sample for \(voiceLabel)" : "Play sample for \(voiceLabel)") } .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { onSelect() } .accessibilityElement(children: .combine) .accessibilityAddTraits(isSelected ? [.isSelected] : []) } }