Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
22 changes: 22 additions & 0 deletions Copy/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
41 changes: 41 additions & 0 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String>) -> 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
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions Copy/Support/CopySoundPlayer.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
20 changes: 19 additions & 1 deletion CopyCore/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading