diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 56f3386..69abd39 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -381,6 +381,7 @@ final class AppCoordinator { // Already on the main queue; hop into main-actor isolation for // the one-time activation nudges (see the methods). MainActor.assumeIsolated { + CopySoundPlayer.shared.play(settings.copySound) AppCoordinator.showFirstCopyCoachIfNeeded() if !pasteStackModel.isActive { AppCoordinator.notePasteStackOpportunity() @@ -616,6 +617,9 @@ final class AppCoordinator { } pasteService.place(reps, plainTextOnly: false) try? store.touch(itemID: id) + // The monitor ignores our own marked write, so the capture path below never + // sees this copy and the feedback has to be asked for here. + CopySoundPlayer.shared.play(settings.copySound) return true } diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 7ec15a6..56d1784 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -55,6 +55,28 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) } + Section { + HStack { + Text("Copy Sound") + Spacer() + Picker("Copy Sound", selection: $settings.copySound) { + ForEach(CopySound.allCases) { sound in + Text(sound.title).tag(sound) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 120) + } + .onChange(of: settings.copySound) { _, sound in + CopySoundPlayer.shared.play(sound) + } + } footer: { + Text("Plays after Copy captures a new clipboard item.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { Toggle("Launch at Login", isOn: launchAtLoginBinding) } footer: { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index f5acc75..00a651e 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -1,6 +1,35 @@ +import AppKit import Foundation import Observation +/// Optional feedback played after Copy successfully records a clipboard change. +/// Raw values are persisted in UserDefaults, so keep them stable across releases. +/// Each case names a sound macOS ships in `/System/Library/Sounds`; adding another of +/// those (Bottle, Glass, Morse, Purr...) is one case here and nothing else. +enum CopySound: String, CaseIterable, Identifiable { + case off + case pop + case tink + + var id: Self { self } + + var title: String { + switch self { + case .off: return "Off" + case .pop: return "Pop" + case .tink: return "Tink" + } + } + + var systemSoundName: NSSound.Name? { + switch self { + case .off: return nil + case .pop: return "Pop" + case .tink: return "Tink" + } + } +} + /// How long unfavorited, unpinned history items are kept before pruning. enum RetentionPeriod: String, CaseIterable { case unlimited @@ -57,6 +86,7 @@ final class SettingsStore { static let shelfProDarkKey = "shelfProDark" static let hideMenuBarIconKey = "hideMenuBarIcon" static let doubleClickToPasteKey = "doubleClickToPaste" + static let copySoundKey = "copySound" var retention: RetentionPeriod { didSet { @@ -154,6 +184,15 @@ final class SettingsStore { } } + /// Sound feedback for successful clipboard captures. Off is the intentional + /// default so installing or updating Copy never adds noise without consent. + var copySound: CopySound { + didSet { + guard copySound != oldValue else { return } + defaults.set(copySound.rawValue, forKey: Self.copySoundKey) + } + } + @ObservationIgnored var onRulesChange: ((Set) -> Void)? /// Fired by the About pane's "Check for Updates…" button. Bridged to Sparkle's /// `updaterController` in `AppDelegate` (which owns it), so this store — and the @@ -191,6 +230,8 @@ final class SettingsStore { shelfProDark = (defaults.object(forKey: Self.shelfProDarkKey) as? Bool) ?? false hideMenuBarIcon = (defaults.object(forKey: Self.hideMenuBarIconKey) as? Bool) ?? false doubleClickToPaste = (defaults.object(forKey: Self.doubleClickToPasteKey) as? Bool) ?? true + copySound = defaults.string(forKey: Self.copySoundKey) + .flatMap(CopySound.init(rawValue:)) ?? .off if let data = defaults.data(forKey: Self.excludedBundleIDsKey), let decoded = try? JSONDecoder().decode([String].self, from: data) { excludedBundleIDs = decoded.sorted() diff --git a/Copy/Support/CopySoundPlayer.swift b/Copy/Support/CopySoundPlayer.swift new file mode 100644 index 0000000..eee84b6 --- /dev/null +++ b/Copy/Support/CopySoundPlayer.swift @@ -0,0 +1,35 @@ +import AppKit + +/// Plays the optional feedback for a clipboard capture. The sounds are the ones macOS +/// already ships in `/System/Library/Sounds`, so Copy bundles no audio of its own: a +/// stock-audio licence that permits redistribution from a public GPL repository is hard +/// to satisfy (both Pixabay and Mixkit forbid handing their files over on their own, and +/// a file in this repo is downloadable on its own), and a system sound already follows +/// the listener's output device and alert volume. +@MainActor +final class CopySoundPlayer { + static let shared = CopySoundPlayer() + + private var cache: [CopySound: NSSound] = [:] + private var playing: NSSound? + + private init() {} + + func play(_ choice: CopySound) { + // A burst of copies should sound like the latest one, not like all of them. + playing?.stop() + playing = nil + + guard let name = choice.systemSoundName else { return } + let sound: NSSound + if let cached = cache[choice] { + sound = cached + } else { + guard let loaded = NSSound(named: name) else { return } + cache[choice] = loaded + sound = loaded + } + playing = sound + sound.play() + } +} diff --git a/CopyCore/Package.resolved b/CopyCore/Package.resolved index 6facdf9..2e67ef0 100644 --- a/CopyCore/Package.resolved +++ b/CopyCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "09f24667467c5ab460cd31c7e1845c760fc02ca2ebe0b3d769c8064d4351ba2d", + "originHash" : "f33f996e4954e262b1ab1dee3bd9c3637a3f988641ba7bb07afa3cffc03c2f22", "pins" : [ { "identity" : "grdb.swift", @@ -9,6 +9,24 @@ "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", "version" : "7.11.1" } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "49c3fc04ea827f816df67843bfcc57286b47ff06", + "version" : "3.0.1" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", + "version" : "2.9.4" + } } ], "version" : 3