Skip to content

Commit 237a41d

Browse files
BohdanBohdan
authored andcommitted
Added sound on input switch
1 parent 995a830 commit 237a41d

17 files changed

+177
-7
lines changed

LanguageFlag/Application/AppDelegate.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1717
private let screenManager: ScreenManager
1818
private let notificationManager: NotificationManager
1919
private let capsLockManager: CapsLockManager
20+
private let soundManager: SoundManager
2021

2122
// MARK: - Initialization
2223
override init() {
2324
self.screenManager = ScreenManager()
2425
self.capsLockManager = CapsLockManager()
2526
self.notificationManager = NotificationManager(capsLockManager: capsLockManager)
27+
self.soundManager = SoundManager()
2628

2729
super.init()
2830
}
2931

3032
// MARK: - Life cycle
3133
func applicationDidFinishLaunching(_ aNotification: Notification) {
32-
statusBarManager = StatusBarManager()
34+
statusBarManager = StatusBarManager(soundManager: soundManager)
3335
dockIconManager = DockIconManager()
3436

3537
// Disable window restoration for menu bar app
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import AppKit
2+
3+
final class SoundManager {
4+
5+
private let preferences = UserPreferences.shared
6+
private let cache: [SoundEffect: NSSound]
7+
8+
// MARK: - Init
9+
init() {
10+
var loaded: [SoundEffect: NSSound] = [:]
11+
12+
for effect in SoundEffect.allCases {
13+
if
14+
let url = Bundle.main.url(forResource: effect.rawValue, withExtension: "wav"),
15+
let sound = NSSound(contentsOf: url, byReference: false)
16+
{
17+
loaded[effect] = sound
18+
}
19+
}
20+
21+
cache = loaded
22+
23+
NotificationCenter.default.addObserver(
24+
self,
25+
selector: #selector(handleLayoutChange),
26+
name: .keyboardLayoutChanged,
27+
object: nil
28+
)
29+
}
30+
31+
// MARK: - Public
32+
func previewSound(_ effect: SoundEffect) {
33+
play(effect)
34+
}
35+
}
36+
37+
// MARK: - Private
38+
private extension SoundManager {
39+
40+
@objc
41+
func handleLayoutChange() {
42+
guard preferences.playSoundOnSwitch else { return }
43+
44+
play(preferences.selectedSoundEffect)
45+
}
46+
47+
func play(_ effect: SoundEffect) {
48+
// Copy the cached sound so it can be played even if already playing
49+
guard let sound = cache[effect]?.copy() as? NSSound else { return }
50+
51+
sound.play()
52+
}
53+
}

LanguageFlag/Helpers/StatusBarManager.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ final class StatusBarManager {
2121
private let menuBuilder: StatusBarMenuBuilder
2222
private let menuDelegate = MenuDelegate()
2323
private var previousModel: KeyboardLayoutNotification?
24-
private lazy var preferencesWindowController = PreferencesWindowController()
24+
private let soundManager: SoundManager
25+
private lazy var preferencesWindowController: PreferencesWindowController = {
26+
PreferencesWindowController(soundManager: soundManager)
27+
}()
2528
private let preferences = UserPreferences.shared
2629
#if FEATURE_ANALYTICS
2730
private let analytics = LayoutAnalytics.shared
@@ -30,9 +33,11 @@ final class StatusBarManager {
3033

3134
// MARK: - Initialization
3235
init(
36+
soundManager: SoundManager,
3337
layoutImageContainer: LayoutImageContainer = LayoutImageContainer.shared,
3438
menuBuilder: StatusBarMenuBuilder = StatusBarMenuBuilder()
3539
) {
40+
self.soundManager = soundManager
3641
self.layoutImageContainer = layoutImageContainer
3742
self.menuBuilder = menuBuilder
3843
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

LanguageFlag/Models/UserPreferences.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ enum WindowSize: String, Codable, CaseIterable {
5555
}
5656
}
5757

58+
enum SoundEffect: String, Codable, CaseIterable {
59+
60+
case blip = "sound-blip"
61+
case click = "sound-click"
62+
case click2 = "sound-click-2"
63+
case ding = "sound-ding"
64+
case notify = "sound-notify"
65+
case notify2 = "sound-notify-2"
66+
case notify3 = "sound-notify-3"
67+
case pop = "sound-pop"
68+
case `switch` = "sound-switch"
69+
case switch2 = "sound-switch-2"
70+
71+
var displayName: String {
72+
switch self {
73+
case .blip: return "Blip"
74+
case .click: return "Click"
75+
case .click2: return "Click 2"
76+
case .ding: return "Ding"
77+
case .notify: return "Notification"
78+
case .notify2: return "Notification 2"
79+
case .notify3: return "Notification 3"
80+
case .pop: return "Pop"
81+
case .switch: return "Switch"
82+
case .switch2: return "Switch 2"
83+
}
84+
}
85+
}
86+
5887
enum AnimationStyle: String, Codable, CaseIterable {
5988

6089
case fade = "Fade"
@@ -100,6 +129,8 @@ final class UserPreferences: ObservableObject {
100129
static let showCapsLockIndicator = "showCapsLockIndicator"
101130
static let bypassClick = "bypassClick"
102131
static let showDockIndicator = "showDockIndicator"
132+
static let playSoundOnSwitch = "playSoundOnSwitch"
133+
static let selectedSoundEffect = "selectedSoundEffect"
103134
}
104135

105136
// MARK: - Published Properties
@@ -163,6 +194,18 @@ final class UserPreferences: ObservableObject {
163194
didSet { defaults.set(showDockIndicator, forKey: Keys.showDockIndicator) }
164195
}
165196

197+
@Published var playSoundOnSwitch: Bool {
198+
didSet { defaults.set(playSoundOnSwitch, forKey: Keys.playSoundOnSwitch) }
199+
}
200+
201+
@Published var selectedSoundEffect: SoundEffect {
202+
didSet {
203+
if let encoded = try? JSONEncoder().encode(selectedSoundEffect) {
204+
defaults.set(encoded, forKey: Keys.selectedSoundEffect)
205+
}
206+
}
207+
}
208+
166209
// MARK: - Initialization
167210
private init() {
168211
self.defaults = .standard
@@ -175,9 +218,11 @@ final class UserPreferences: ObservableObject {
175218
self.showCapsLockIndicator = true
176219
self.bypassClick = true
177220
self.showDockIndicator = false
221+
self.playSoundOnSwitch = false
178222
self.displayPosition = .bottomCenter
179223
self.windowSize = .medium
180224
self.animationStyle = .fade
225+
self.selectedSoundEffect = .click
181226

182227
loadSavedPreferences()
183228
}
@@ -194,9 +239,11 @@ final class UserPreferences: ObservableObject {
194239
self.showCapsLockIndicator = true
195240
self.bypassClick = true
196241
self.showDockIndicator = false
242+
self.playSoundOnSwitch = false
197243
self.displayPosition = .bottomCenter
198244
self.windowSize = .medium
199245
self.animationStyle = .fade
246+
self.selectedSoundEffect = .click
200247

201248
loadSavedPreferences()
202249
}
@@ -212,6 +259,7 @@ final class UserPreferences: ObservableObject {
212259
self.showCapsLockIndicator = defaults.object(forKey: Keys.showCapsLockIndicator) as? Bool ?? true
213260
self.bypassClick = defaults.object(forKey: Keys.bypassClick) as? Bool ?? true
214261
self.showDockIndicator = defaults.object(forKey: Keys.showDockIndicator) as? Bool ?? false
262+
self.playSoundOnSwitch = defaults.object(forKey: Keys.playSoundOnSwitch) as? Bool ?? false
215263

216264
// Decode complex types
217265
if
@@ -240,6 +288,15 @@ final class UserPreferences: ObservableObject {
240288
} else {
241289
self.animationStyle = .fade
242290
}
291+
292+
if
293+
let data = defaults.data(forKey: Keys.selectedSoundEffect),
294+
let decoded = try? JSONDecoder().decode(SoundEffect.self, from: data)
295+
{
296+
self.selectedSoundEffect = decoded
297+
} else {
298+
self.selectedSoundEffect = .click
299+
}
243300
}
244301

245302
func resetToDefaults() {
@@ -255,5 +312,7 @@ final class UserPreferences: ObservableObject {
255312
showCapsLockIndicator = true
256313
bypassClick = true
257314
showDockIndicator = false
315+
playSoundOnSwitch = false
316+
selectedSoundEffect = .click
258317
}
259318
}

LanguageFlag/Stories/Preferences/Panes/GeneralPreferencesPane.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ struct GeneralPreferencesPane: View {
1111
@Binding var showCapsLockIndicator: Bool
1212
@Binding var bypassClick: Bool
1313
@Binding var showDockIndicator: Bool
14+
@Binding var playSoundOnSwitch: Bool
15+
@Binding var selectedSoundEffect: SoundEffect
1416

1517
let onReset: () -> Void
18+
let soundManager: SoundManager
1619

1720
@State private var showResetConfirmation = false
1821

@@ -153,6 +156,43 @@ struct GeneralPreferencesPane: View {
153156
.accessibilityIdentifier("dockIndicatorToggle")
154157
}
155158

159+
// Sound on Switch
160+
HStack {
161+
VStack(alignment: .leading, spacing: 4) {
162+
Text("Play sound on input switch")
163+
Text("Play a sound when the keyboard layout changes.")
164+
.font(.caption)
165+
.foregroundColor(.secondary)
166+
167+
if playSoundOnSwitch {
168+
HStack(spacing: 8) {
169+
Picker("Sound", selection: $selectedSoundEffect) {
170+
ForEach(SoundEffect.allCases, id: \.self) { effect in
171+
Text(effect.displayName).tag(effect)
172+
}
173+
}
174+
.labelsHidden()
175+
176+
Button {
177+
soundManager.previewSound(selectedSoundEffect)
178+
} label: {
179+
Image(systemName: "play.circle")
180+
}
181+
.buttonStyle(.borderless)
182+
.help("Preview sound")
183+
}
184+
.padding(.top, 4)
185+
}
186+
}
187+
188+
Spacer()
189+
190+
Toggle("Play sound on input switch", isOn: $playSoundOnSwitch)
191+
.toggleStyle(.switch)
192+
.labelsHidden()
193+
.accessibilityIdentifier("playSoundToggle")
194+
}
195+
156196
// Launch at Login Toggle
157197
HStack {
158198
VStack(alignment: .leading, spacing: 4) {

LanguageFlag/Stories/Preferences/PreferencesView.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import SwiftUI
66
/// Kept separate so that PreferencesView.body never re-runs due to UserPreferences changes.
77
struct PreferencesView: View {
88

9+
let soundManager: SoundManager
10+
911
@StateObject private var preferences = UserPreferences.shared
1012

1113
var body: some View {
@@ -16,13 +18,16 @@ struct PreferencesView: View {
1618
showCapsLockIndicator: $preferences.showCapsLockIndicator,
1719
bypassClick: $preferences.bypassClick,
1820
showDockIndicator: $preferences.showDockIndicator,
21+
playSoundOnSwitch: $preferences.playSoundOnSwitch,
22+
selectedSoundEffect: $preferences.selectedSoundEffect,
1923
opacity: $preferences.opacity,
2024
animationStyle: $preferences.animationStyle,
2125
animationDuration: $preferences.animationDuration,
2226
displayDuration: $preferences.displayDuration,
2327
resetAnimationOnChange: $preferences.resetAnimationOnChange,
2428
showShortcuts: $preferences.showShortcuts,
25-
onReset: { UserPreferences.shared.resetToDefaults() }
29+
onReset: { UserPreferences.shared.resetToDefaults() },
30+
soundManager: soundManager
2631
)
2732
}
2833
}
@@ -40,13 +45,16 @@ private struct PreferencesContentView: View {
4045
@Binding var showCapsLockIndicator: Bool
4146
@Binding var bypassClick: Bool
4247
@Binding var showDockIndicator: Bool
48+
@Binding var playSoundOnSwitch: Bool
49+
@Binding var selectedSoundEffect: SoundEffect
4350
@Binding var opacity: Double
4451
@Binding var animationStyle: AnimationStyle
4552
@Binding var animationDuration: Double
4653
@Binding var displayDuration: Double
4754
@Binding var resetAnimationOnChange: Bool
4855
@Binding var showShortcuts: Bool
4956
let onReset: () -> Void
57+
let soundManager: SoundManager
5058

5159
@State private var selectedPane: PreferencePane = .general
5260

@@ -76,7 +84,10 @@ private struct PreferencesContentView: View {
7684
showCapsLockIndicator: $showCapsLockIndicator,
7785
bypassClick: $bypassClick,
7886
showDockIndicator: $showDockIndicator,
79-
onReset: onReset
87+
playSoundOnSwitch: $playSoundOnSwitch,
88+
selectedSoundEffect: $selectedSoundEffect,
89+
onReset: onReset,
90+
soundManager: soundManager
8091
)
8192

8293
case .appearance:
@@ -122,6 +133,6 @@ private struct PreferencesContentView: View {
122133
struct PreferencesView_Previews: PreviewProvider {
123134

124135
static var previews: some View {
125-
PreferencesView()
136+
PreferencesView(soundManager: SoundManager())
126137
}
127138
}

LanguageFlag/Stories/Preferences/PreferencesWindowController.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate {
1414
private var settingsWindow: NSWindow?
1515

1616
// MARK: - Init
17-
convenience init() {
18-
let hostingController = NSHostingController(rootView: PreferencesView())
17+
convenience init(soundManager: SoundManager) {
18+
let hostingController = NSHostingController(rootView: PreferencesView(soundManager: soundManager))
1919
let window = NSWindow(contentViewController: hostingController)
2020

2121
window.title = "LanguageFlag Preferences"
16.1 KB
Binary file not shown.
16.1 KB
Binary file not shown.
16.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)