diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index d124eac..3b4f4db 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3382,6 +3382,10 @@ } } }, + "Audio feedback" : { + "comment" : "A section header that lists audio feedback settings.", + "isCommentAutoGenerated" : true + }, "Authorize" : { "comment" : "A button that authorizes an app extension.", "isCommentAutoGenerated" : true @@ -15194,6 +15198,10 @@ }, "Pinned Items" : { + }, + "Plays the supplied feedback clip whenever you press the hardware volume keys." : { + "comment" : "A help text describing the audio feedback feature.", + "isCommentAutoGenerated" : true }, "Please configure your API key for the selected AI provider in model settings." : { @@ -16032,6 +16040,9 @@ } } } + }, + "Requires Accessibility permission so Dynamic Island can intercept the hardware volume keys." : { + }, "Reset" : { "localizations" : { diff --git a/DynamicIsland/audio-feedback.m4a b/DynamicIsland/audio-feedback.m4a new file mode 100644 index 0000000..b0eb634 Binary files /dev/null and b/DynamicIsland/audio-feedback.m4a differ diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 59d65dc..069d625 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1859,6 +1859,18 @@ struct HUD: View { } } + Section { + Defaults.Toggle("Play feedback when volume is changed", key: .playVolumeChangeFeedback) + .settingsHighlight(id: highlightID("Play feedback when volume is changed")) + .help("Plays the supplied feedback clip whenever you press the hardware volume keys.") + } header: { + Text("Audio feedback") + } footer: { + Text("Requires Accessibility permission so Dynamic Island can intercept the hardware volume keys.") + .foregroundStyle(.secondary) + .font(.caption) + } + Section { Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay) .disabled(colorCodingDisabled) diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift index 980d9de..3491dcf 100644 --- a/DynamicIsland/managers/SystemChangesObserver.swift +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -2,6 +2,7 @@ import Foundation import CoreGraphics import Defaults import AppKit +import AVFoundation final class SystemChangesObserver: MediaKeyInterceptorDelegate { private weak var coordinator: DynamicIslandViewCoordinator? @@ -9,6 +10,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { private let brightnessController = SystemBrightnessController.shared private let keyboardBacklightController = SystemKeyboardBacklightController.shared private let mediaKeyInterceptor = MediaKeyInterceptor.shared + private let volumeFeedbackPlayer = VolumeFeedbackPlayer.shared private static let headsetIconSymbols: Set = [ "airpods", @@ -140,6 +142,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { let baseStep = stepSize(for: step, base: standardVolumeStep) let delta = direction == .up ? baseStep : -baseStep volumeController.adjust(by: delta) + volumeFeedbackPlayer.playIfNeeded() } func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) { @@ -325,4 +328,53 @@ private extension SystemChangesObserver { } } +private final class VolumeFeedbackPlayer { + static let shared = VolumeFeedbackPlayer() + + private let queue = DispatchQueue(label: "com.dynamicisland.volume-feedback-player") + private var player: AVAudioPlayer? + private var lastPlayDate: Date = .distantPast + private let minimumInterval: TimeInterval = 0.08 + private var didLogMissingAsset = false + + private init() {} + + func playIfNeeded() { + guard Defaults[.playVolumeChangeFeedback] else { return } + queue.async { [weak self] in + guard let self else { return } + let now = Date() + guard now.timeIntervalSince(self.lastPlayDate) >= self.minimumInterval else { return } + self.lastPlayDate = now + self.preparePlayerIfNeeded() + guard let player = self.player else { return } + player.currentTime = 0 + player.play() + } + } + + private func preparePlayerIfNeeded() { + if player != nil { return } + guard let url = Bundle.main.url(forResource: "audio-feedback", withExtension: "m4a") else { + if !didLogMissingAsset { + NSLog("⚠️ audio-feedback.m4a is missing from the app bundle; volume feedback disabled.") + didLogMissingAsset = true + } + return + } + + do { + let audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer.volume = 1 + audioPlayer.prepareToPlay() + player = audioPlayer + } catch { + if !didLogMissingAsset { + NSLog("⚠️ Failed to initialize volume feedback player: \(error.localizedDescription)") + didLogMissingAsset = true + } + } + } +} + diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index f5833d5..1db0658 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -871,6 +871,7 @@ extension Defaults.Keys { static let enableBrightnessHUD = Key("enableBrightnessHUD", default: true) static let enableKeyboardBacklightHUD = Key("enableKeyboardBacklightHUD", default: true) static let systemHUDSensitivity = Key("systemHUDSensitivity", default: 5) + static let playVolumeChangeFeedback = Key("playVolumeChangeFeedback", default: false) // MARK: Custom OSD Window Feature static let enableCustomOSD = Key("enableCustomOSD", default: false)