Feat: Add custom start/stop sounds and fix race condition
This commit is contained in:
parent
35a08dce7b
commit
7365493366
195
VoiceInk/CustomSoundManager.swift
Normal file
195
VoiceInk/CustomSoundManager.swift
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class CustomSoundManager: ObservableObject {
|
||||||
|
static let shared = CustomSoundManager()
|
||||||
|
|
||||||
|
enum SoundType: String {
|
||||||
|
case start
|
||||||
|
case stop
|
||||||
|
|
||||||
|
var isUsingKey: String { "isUsingCustom\(rawValue.capitalized)Sound" }
|
||||||
|
var filenameKey: String { "custom\(rawValue.capitalized)SoundFilename" }
|
||||||
|
var standardName: String { "Custom\(rawValue.capitalized)Sound" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var isUsingCustomStartSound: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(isUsingCustomStartSound, forKey: SoundType.start.isUsingKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var isUsingCustomStopSound: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(isUsingCustomStopSound, forKey: SoundType.stop.isUsingKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let maxSoundDuration: TimeInterval = 3.0
|
||||||
|
|
||||||
|
private var customStartSoundFilename: String? {
|
||||||
|
didSet { updateFilenameInUserDefaults(filename: customStartSoundFilename, for: .start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customStopSoundFilename: String? {
|
||||||
|
didSet { updateFilenameInUserDefaults(filename: customStopSoundFilename, for: .stop) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFilenameInUserDefaults(filename: String?, for type: SoundType) {
|
||||||
|
if let filename = filename {
|
||||||
|
UserDefaults.standard.set(filename, forKey: type.filenameKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: type.filenameKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.isUsingCustomStartSound = UserDefaults.standard.bool(forKey: SoundType.start.isUsingKey)
|
||||||
|
self.isUsingCustomStopSound = UserDefaults.standard.bool(forKey: SoundType.stop.isUsingKey)
|
||||||
|
self.customStartSoundFilename = UserDefaults.standard.string(forKey: SoundType.start.filenameKey)
|
||||||
|
self.customStopSoundFilename = UserDefaults.standard.string(forKey: SoundType.stop.filenameKey)
|
||||||
|
|
||||||
|
createCustomSoundsDirectoryIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func customSoundsDirectory() -> URL? {
|
||||||
|
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return appSupport.appendingPathComponent("VoiceInk/CustomSounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createCustomSoundsDirectoryIfNeeded() {
|
||||||
|
guard let directory = customSoundsDirectory() else { return }
|
||||||
|
|
||||||
|
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCustomSoundURL(for type: SoundType) -> URL? {
|
||||||
|
let isUsing = (type == .start) ? isUsingCustomStartSound : isUsingCustomStopSound
|
||||||
|
let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename
|
||||||
|
|
||||||
|
guard isUsing, let filename = filename, let directory = customSoundsDirectory() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return directory.appendingPathComponent(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCustomSound(url: URL, for type: SoundType) -> Result<Void, CustomSoundError> {
|
||||||
|
let result = validateAudioFile(url: url)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
let copyResult = copySoundFile(from: url, standardName: type.standardName)
|
||||||
|
switch copyResult {
|
||||||
|
case .success(let filename):
|
||||||
|
if type == .start {
|
||||||
|
customStartSoundFilename = filename
|
||||||
|
isUsingCustomStartSound = true
|
||||||
|
} else {
|
||||||
|
customStopSoundFilename = filename
|
||||||
|
isUsingCustomStopSound = true
|
||||||
|
}
|
||||||
|
notifyCustomSoundsChanged()
|
||||||
|
return .success(())
|
||||||
|
case .failure(let error):
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSoundToDefault(for type: SoundType) {
|
||||||
|
let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename
|
||||||
|
|
||||||
|
if let filename = filename, let directory = customSoundsDirectory() {
|
||||||
|
let fileURL = directory.appendingPathComponent(filename)
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == .start {
|
||||||
|
isUsingCustomStartSound = false
|
||||||
|
customStartSoundFilename = nil
|
||||||
|
} else {
|
||||||
|
isUsingCustomStopSound = false
|
||||||
|
customStopSoundFilename = nil
|
||||||
|
}
|
||||||
|
notifyCustomSoundsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyCustomSoundsChanged() {
|
||||||
|
NotificationCenter.default.post(name: NSNotification.Name("CustomSoundsChanged"), object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSoundDisplayName(for type: SoundType) -> String? {
|
||||||
|
return (type == .start) ? customStartSoundFilename : customStopSoundFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copySoundFile(from sourceURL: URL, standardName: String) -> Result<String, CustomSoundError> {
|
||||||
|
guard let directory = customSoundsDirectory() else {
|
||||||
|
return .failure(.directoryCreationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileExtension = sourceURL.pathExtension
|
||||||
|
let newFilename = "\(standardName).\(fileExtension)"
|
||||||
|
let destinationURL = directory.appendingPathComponent(newFilename)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||||
|
try? FileManager.default.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
return .success(newFilename)
|
||||||
|
} catch {
|
||||||
|
return .failure(.fileCopyFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateAudioFile(url: URL) -> Result<Void, CustomSoundError> {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return .failure(.fileNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset = AVAsset(url: url)
|
||||||
|
let duration = asset.duration.seconds
|
||||||
|
|
||||||
|
guard duration.isFinite && duration > 0 else {
|
||||||
|
return .failure(.invalidAudioFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration > maxSoundDuration {
|
||||||
|
return .failure(.durationTooLong(duration: duration, maxDuration: maxSoundDuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try AVAudioPlayer(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
return .failure(.invalidAudioFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CustomSoundError: LocalizedError {
|
||||||
|
case fileNotFound
|
||||||
|
case invalidAudioFile
|
||||||
|
case durationTooLong(duration: TimeInterval, maxDuration: TimeInterval)
|
||||||
|
case directoryCreationFailed
|
||||||
|
case fileCopyFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .fileNotFound:
|
||||||
|
return "Audio file not found"
|
||||||
|
case .invalidAudioFile:
|
||||||
|
return "Invalid audio file format"
|
||||||
|
case .durationTooLong(let duration, let maxDuration):
|
||||||
|
return String(format: "Audio file is %.1f seconds long. Please use an audio file that is %.0f seconds or shorter for start and stop sounds.", duration, maxDuration)
|
||||||
|
case .directoryCreationFailed:
|
||||||
|
return "Failed to create custom sounds directory"
|
||||||
|
case .fileCopyFailed:
|
||||||
|
return "Failed to copy audio file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,45 +2,72 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class SoundManager {
|
@MainActor
|
||||||
|
class SoundManager: ObservableObject {
|
||||||
static let shared = SoundManager()
|
static let shared = SoundManager()
|
||||||
|
|
||||||
private var startSound: AVAudioPlayer?
|
private var startSound: AVAudioPlayer?
|
||||||
private var stopSound: AVAudioPlayer?
|
private var stopSound: AVAudioPlayer?
|
||||||
private var escSound: AVAudioPlayer?
|
private var escSound: AVAudioPlayer?
|
||||||
|
private var customStartSound: AVAudioPlayer?
|
||||||
|
private var customStopSound: AVAudioPlayer?
|
||||||
|
|
||||||
@AppStorage("isSoundFeedbackEnabled") private var isSoundFeedbackEnabled = true
|
@AppStorage("isSoundFeedbackEnabled") private var isSoundFeedbackEnabled = true
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
Task(priority: .background) {
|
Task(priority: .background) {
|
||||||
await setupSounds()
|
await setupSounds()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(reloadCustomSounds),
|
||||||
|
name: NSNotification.Name("CustomSoundsChanged"),
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSounds() async {
|
func setupSounds() async {
|
||||||
// Try loading directly from the main bundle
|
|
||||||
if let startSoundURL = Bundle.main.url(forResource: "recstart", withExtension: "mp3"),
|
if let startSoundURL = Bundle.main.url(forResource: "recstart", withExtension: "mp3"),
|
||||||
let stopSoundURL = Bundle.main.url(forResource: "recstop", withExtension: "mp3"),
|
let stopSoundURL = Bundle.main.url(forResource: "recstop", withExtension: "mp3"),
|
||||||
let escSoundURL = Bundle.main.url(forResource: "esc", withExtension: "wav") {
|
let escSoundURL = Bundle.main.url(forResource: "esc", withExtension: "wav") {
|
||||||
try? await loadSounds(start: startSoundURL, stop: stopSoundURL, esc: escSoundURL)
|
try? await loadSounds(start: startSoundURL, stop: stopSoundURL, esc: escSoundURL)
|
||||||
return
|
}
|
||||||
|
|
||||||
|
await reloadCustomSoundsAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func reloadCustomSounds() {
|
||||||
|
Task {
|
||||||
|
await reloadCustomSoundsAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadAndPreparePlayer(from url: URL?) -> AVAudioPlayer? {
|
||||||
|
guard let url = url else { return nil }
|
||||||
|
let player = try? AVAudioPlayer(contentsOf: url)
|
||||||
|
player?.volume = 0.4
|
||||||
|
player?.prepareToPlay()
|
||||||
|
return player
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadCustomSoundsAsync() async {
|
||||||
|
customStartSound = loadAndPreparePlayer(from: CustomSoundManager.shared.getCustomSoundURL(for: .start))
|
||||||
|
customStopSound = loadAndPreparePlayer(from: CustomSoundManager.shared.getCustomSoundURL(for: .stop))
|
||||||
|
}
|
||||||
|
|
||||||
private func loadSounds(start startURL: URL, stop stopURL: URL, esc escURL: URL) async throws {
|
private func loadSounds(start startURL: URL, stop stopURL: URL, esc escURL: URL) async throws {
|
||||||
do {
|
do {
|
||||||
startSound = try AVAudioPlayer(contentsOf: startURL)
|
startSound = try AVAudioPlayer(contentsOf: startURL)
|
||||||
stopSound = try AVAudioPlayer(contentsOf: stopURL)
|
stopSound = try AVAudioPlayer(contentsOf: stopURL)
|
||||||
escSound = try AVAudioPlayer(contentsOf: escURL)
|
escSound = try AVAudioPlayer(contentsOf: escURL)
|
||||||
|
|
||||||
// Prepare sounds for instant playback first
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
startSound?.prepareToPlay()
|
startSound?.prepareToPlay()
|
||||||
stopSound?.prepareToPlay()
|
stopSound?.prepareToPlay()
|
||||||
escSound?.prepareToPlay()
|
escSound?.prepareToPlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set lower volume for all sounds after preparation
|
|
||||||
startSound?.volume = 0.4
|
startSound?.volume = 0.4
|
||||||
stopSound?.volume = 0.4
|
stopSound?.volume = 0.4
|
||||||
escSound?.volume = 0.3
|
escSound?.volume = 0.3
|
||||||
@ -48,17 +75,27 @@ class SoundManager {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStartSound() {
|
func playStartSound() {
|
||||||
guard isSoundFeedbackEnabled else { return }
|
guard isSoundFeedbackEnabled else { return }
|
||||||
startSound?.volume = 0.4
|
|
||||||
startSound?.play()
|
if let custom = customStartSound {
|
||||||
|
custom.play()
|
||||||
|
} else {
|
||||||
|
startSound?.volume = 0.4
|
||||||
|
startSound?.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStopSound() {
|
func playStopSound() {
|
||||||
guard isSoundFeedbackEnabled else { return }
|
guard isSoundFeedbackEnabled else { return }
|
||||||
stopSound?.volume = 0.4
|
|
||||||
stopSound?.play()
|
if let custom = customStopSound {
|
||||||
|
custom.play()
|
||||||
|
} else {
|
||||||
|
stopSound?.volume = 0.4
|
||||||
|
stopSound?.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playEscSound() {
|
func playEscSound() {
|
||||||
@ -69,6 +106,9 @@ class SoundManager {
|
|||||||
|
|
||||||
var isEnabled: Bool {
|
var isEnabled: Bool {
|
||||||
get { isSoundFeedbackEnabled }
|
get { isSoundFeedbackEnabled }
|
||||||
set { isSoundFeedbackEnabled = newValue }
|
set {
|
||||||
|
objectWillChange.send()
|
||||||
|
isSoundFeedbackEnabled = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
VoiceInk/Views/Settings/CustomSoundSettingsView.swift
Normal file
145
VoiceInk/Views/Settings/CustomSoundSettingsView.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct CustomSoundSettingsView: View {
|
||||||
|
@StateObject private var customSoundManager = CustomSoundManager.shared
|
||||||
|
@State private var showingAlert = false
|
||||||
|
@State private var alertTitle = ""
|
||||||
|
@State private var alertMessage = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
soundRow(for: .start)
|
||||||
|
soundRow(for: .stop)
|
||||||
|
}
|
||||||
|
.alert(alertTitle, isPresented: $showingAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(alertMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func soundRow(for type: CustomSoundManager.SoundType) -> some View {
|
||||||
|
horizontalSoundRow(
|
||||||
|
title: type.rawValue.capitalized,
|
||||||
|
fileName: customSoundManager.getSoundDisplayName(for: type),
|
||||||
|
isCustom: type == .start ? customSoundManager.isUsingCustomStartSound : customSoundManager.isUsingCustomStopSound,
|
||||||
|
onSelect: { selectSound(for: type) },
|
||||||
|
onTest: {
|
||||||
|
if type == .start {
|
||||||
|
SoundManager.shared.playStartSound()
|
||||||
|
} else {
|
||||||
|
SoundManager.shared.playStopSound()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReset: { customSoundManager.resetSoundToDefault(for: type) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func horizontalSoundRow(
|
||||||
|
title: String,
|
||||||
|
fileName: String?,
|
||||||
|
isCustom: Bool,
|
||||||
|
onSelect: @escaping () -> Void,
|
||||||
|
onTest: @escaping () -> Void,
|
||||||
|
onReset: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: 40, alignment: .leading)
|
||||||
|
|
||||||
|
if let fileName = fileName, isCustom {
|
||||||
|
Text(fileName)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.frame(maxWidth: 160, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: onTest) {
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Test sound")
|
||||||
|
|
||||||
|
Button(action: onSelect) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Change sound")
|
||||||
|
|
||||||
|
Button(action: onReset) {
|
||||||
|
Image(systemName: "arrow.uturn.backward.circle")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Reset to default")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Default")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: 160, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: onTest) {
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Test sound")
|
||||||
|
|
||||||
|
Button(action: onSelect) {
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Choose custom sound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSound(for type: CustomSoundManager.SoundType) {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.title = "Choose \(type.rawValue.capitalized) Sound"
|
||||||
|
panel.message = "Select an audio file"
|
||||||
|
panel.allowedContentTypes = [
|
||||||
|
UTType.audio,
|
||||||
|
UTType.mp3,
|
||||||
|
UTType.wav,
|
||||||
|
UTType.aiff
|
||||||
|
]
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
|
||||||
|
panel.begin { response in
|
||||||
|
guard response == .OK, let url = panel.url else { return }
|
||||||
|
|
||||||
|
let result = customSoundManager.setCustomSound(url: url, for: type)
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
alertTitle = "Invalid Audio File"
|
||||||
|
alertMessage = error.localizedDescription
|
||||||
|
showingAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CustomSoundSettingsView()
|
||||||
|
.frame(width: 600)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject private var whisperState: WhisperState
|
@EnvironmentObject private var whisperState: WhisperState
|
||||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||||
@StateObject private var deviceManager = AudioDeviceManager.shared
|
@StateObject private var deviceManager = AudioDeviceManager.shared
|
||||||
|
@ObservedObject private var soundManager = SoundManager.shared
|
||||||
@ObservedObject private var mediaController = MediaController.shared
|
@ObservedObject private var mediaController = MediaController.shared
|
||||||
@ObservedObject private var playbackController = PlaybackController.shared
|
@ObservedObject private var playbackController = PlaybackController.shared
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
||||||
@ -19,6 +20,7 @@ struct SettingsView: View {
|
|||||||
@State private var showResetOnboardingAlert = false
|
@State private var showResetOnboardingAlert = false
|
||||||
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
|
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
|
||||||
@State private var isCustomCancelEnabled = false
|
@State private var isCustomCancelEnabled = false
|
||||||
|
@State private var isCustomSoundsExpanded = false
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -217,13 +219,38 @@ struct SettingsView: View {
|
|||||||
subtitle: "Customize app & system feedback"
|
subtitle: "Customize app & system feedback"
|
||||||
) {
|
) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Toggle(isOn: .init(
|
HStack {
|
||||||
get: { SoundManager.shared.isEnabled },
|
Toggle(isOn: $soundManager.isEnabled) {
|
||||||
set: { SoundManager.shared.isEnabled = $0 }
|
Text("Sound feedback")
|
||||||
)) {
|
}
|
||||||
Text("Sound feedback")
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
if soundManager.isEnabled {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isCustomSoundsExpanded.toggle()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.rotationEffect(.degrees(isCustomSoundsExpanded ? 90 : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isCustomSoundsExpanded)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Customize recording sounds")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toggleStyle(.switch)
|
|
||||||
|
if soundManager.isEnabled && isCustomSoundsExpanded {
|
||||||
|
CustomSoundSettingsView()
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
|
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
|
||||||
Text("Mute system audio during recording")
|
Text("Mute system audio during recording")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user