All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
159 lines
4.8 KiB
Swift
159 lines
4.8 KiB
Swift
import SwiftUI
|
|
|
|
struct VoiceSelectionView: View {
|
|
@StateObject private var vm = VoiceSelectionViewModel()
|
|
@EnvironmentObject var authStore: AuthStore
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var selectedVoice: String
|
|
|
|
init(currentVoice: String) {
|
|
_selectedVoice = State(initialValue: currentVoice)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if vm.isLoading {
|
|
ProgressView("Loading voices...")
|
|
} else if let error = vm.error {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(.amber)
|
|
Text(error)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding()
|
|
} else {
|
|
voiceList
|
|
}
|
|
}
|
|
.navigationTitle("Select Voice")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
saveAndDismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
.disabled(selectedVoice == authStore.settings.voice)
|
|
}
|
|
}
|
|
.task {
|
|
await vm.loadVoices()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Voice List
|
|
|
|
@ViewBuilder
|
|
private var voiceList: some View {
|
|
List {
|
|
Section {
|
|
ForEach(vm.voices, id: \.self) { voice in
|
|
VoiceRow(
|
|
voice: voice,
|
|
isSelected: voice == selectedVoice,
|
|
isPlaying: vm.playingVoice == voice,
|
|
voiceLabel: vm.voiceLabel(voice),
|
|
voiceId: vm.voiceId(voice),
|
|
onSelect: {
|
|
vm.stopSample()
|
|
selectedVoice = voice
|
|
},
|
|
onPlaySample: {
|
|
Task {
|
|
await vm.playSample(voice)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
} header: {
|
|
Text("Available Voices")
|
|
} footer: {
|
|
if selectedVoice != authStore.settings.voice {
|
|
Text("New voice will apply to next audio playback")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func saveAndDismiss() {
|
|
Task {
|
|
var settings = authStore.settings
|
|
settings.voice = selectedVoice
|
|
await authStore.saveSettings(settings)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Voice Row
|
|
|
|
private struct VoiceRow: 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 checkmark
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3))
|
|
.frame(width: 28)
|
|
|
|
// Voice info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
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 ? .red : .amber)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onSelect()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
VoiceSelectionView(currentVoice: "af_bella")
|
|
.environmentObject(AuthStore())
|
|
}
|