Initial commit: HUDini — classic volume HUD for macOS

Menu bar utility that shows a centered HUD overlay with volume bars
when system volume changes. CoreAudio monitoring, auto-fade, works
on all spaces and fullscreen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
johnfrum1234 2026-03-10 13:28:41 +02:00
commit cee343dd96
9 changed files with 497 additions and 0 deletions

View file

@ -0,0 +1,70 @@
import Cocoa
import SwiftUI
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var volumeMonitor: VolumeMonitor!
private var hudPanel: HUDPanel!
func applicationDidFinishLaunching(_ notification: Notification) {
// Hide from Dock
NSApp.setActivationPolicy(.accessory)
setupStatusBar()
setupHUD()
setupVolumeMonitor()
}
private func setupStatusBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "HUDini")
button.image?.size = NSSize(width: 18, height: 18)
}
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "HUDini", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "e")
enabledItem.state = .on
enabledItem.target = self
menu.addItem(enabledItem)
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
}
private func setupHUD() {
hudPanel = HUDPanel()
}
private func setupVolumeMonitor() {
volumeMonitor = VolumeMonitor { [weak self] volume, isMuted in
DispatchQueue.main.async {
self?.hudPanel.show(volume: volume, isMuted: isMuted)
}
}
volumeMonitor.start()
}
@objc private func toggleEnabled(_ sender: NSMenuItem) {
if sender.state == .on {
sender.state = .off
volumeMonitor.stop()
} else {
sender.state = .on
volumeMonitor.start()
}
}
@objc private func quit() {
volumeMonitor.stop()
NSApp.terminate(nil)
}
}

View file

@ -0,0 +1,68 @@
import Cocoa
import SwiftUI
class HUDPanel {
private var panel: NSPanel?
private var hideTimer: Timer?
private let hudSize = NSSize(width: 200, height: 200)
func show(volume: Float, isMuted: Bool) {
hideTimer?.invalidate()
if panel == nil {
createPanel()
}
guard let panel = panel else { return }
// Update content
let hostingView = NSHostingView(rootView: HUDView(volume: volume, isMuted: isMuted))
hostingView.frame = NSRect(origin: .zero, size: hudSize)
panel.contentView = hostingView
// Center on main screen
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
let x = screenFrame.midX - hudSize.width / 2
let y = screenFrame.midY - hudSize.height / 2
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
// Show
panel.alphaValue = 1.0
panel.orderFrontRegardless()
// Auto-hide after 1.5s
hideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
self?.fadeOut()
}
}
private func createPanel() {
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: hudSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.level = .floating
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.hidesOnDeactivate = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
panel.isMovableByWindowBackground = false
panel.ignoresMouseEvents = true
self.panel = panel
}
private func fadeOut() {
guard let panel = panel else { return }
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.4
panel.animator().alphaValue = 0.0
}, completionHandler: { [weak self] in
self?.panel?.orderOut(nil)
})
}
}

View file

@ -0,0 +1,51 @@
import SwiftUI
struct HUDView: View {
let volume: Float
let isMuted: Bool
private let totalBars = 16
private let cornerRadius: CGFloat = 18
var filledBars: Int {
if isMuted { return 0 }
return Int(round(Double(volume) * Double(totalBars)))
}
var speakerIcon: String {
if isMuted { return "speaker.slash.fill" }
if volume == 0 { return "speaker.fill" }
if volume < 0.33 { return "speaker.wave.1.fill" }
if volume < 0.66 { return "speaker.wave.2.fill" }
return "speaker.wave.3.fill"
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: speakerIcon)
.font(.system(size: 48, weight: .medium))
.foregroundColor(.white)
.frame(height: 60)
HStack(spacing: 4) {
ForEach(0..<totalBars, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(index < filledBars ? Color.white : Color.white.opacity(0.2))
.frame(height: 24)
}
}
.padding(.horizontal, 20)
}
.frame(width: 200, height: 200)
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
)
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.black.opacity(0.55))
)
}
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>HUDini</string>
<key>CFBundleDisplayName</key>
<string>HUDini</string>
<key>CFBundleIdentifier</key>
<string>com.grosfrumos.hudini</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>HUDini</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,226 @@
import CoreAudio
import AudioToolbox
import Foundation
class VolumeMonitor {
typealias VolumeCallback = (Float, Bool) -> Void
private let callback: VolumeCallback
private var deviceID: AudioDeviceID = kAudioObjectUnknown
private var isListening = false
private var lastVolume: Float = -1
private var lastMuted: Bool = false
init(callback: @escaping VolumeCallback) {
self.callback = callback
}
func start() {
guard !isListening else { return }
deviceID = getDefaultOutputDevice()
guard deviceID != kAudioObjectUnknown else { return }
addDeviceChangeListener()
addVolumeListener(for: deviceID)
addMuteListener(for: deviceID)
isListening = true
}
func stop() {
guard isListening else { return }
removeVolumeListener(for: deviceID)
removeMuteListener(for: deviceID)
removeDeviceChangeListener()
isListening = false
}
// MARK: - Default Output Device
private func getDefaultOutputDevice() -> AudioDeviceID {
var deviceID = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID
)
return status == noErr ? deviceID : kAudioObjectUnknown
}
// MARK: - Volume
func getVolume() -> Float {
guard deviceID != kAudioObjectUnknown else { return 0 }
var volume: Float32 = 0
var size = UInt32(MemoryLayout<Float32>.size)
// Try master channel first
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
var status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume)
if status == noErr { return volume }
// Fallback to channel 1 (left)
address.mElement = 1
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume)
return status == noErr ? volume : 0
}
func isMuted() -> Bool {
guard deviceID != kAudioObjectUnknown else { return false }
var muted: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &muted)
return status == noErr ? muted != 0 : false
}
// MARK: - Listeners
private func addVolumeListener(for device: AudioDeviceID) {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
// Try master first
let status = AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
if status != noErr {
// Fallback: listen on channel 1
address.mElement = 1
AudioObjectAddPropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
}
}
private func removeVolumeListener(for device: AudioDeviceID) {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
address.mElement = 1
AudioObjectRemovePropertyListener(device, &address, volumeChanged, Unmanaged.passUnretained(self).toOpaque())
}
private func addMuteListener(for device: AudioDeviceID) {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectAddPropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque())
}
private func removeMuteListener(for device: AudioDeviceID) {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListener(device, &address, muteChanged, Unmanaged.passUnretained(self).toOpaque())
}
private func addDeviceChangeListener() {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectAddPropertyListener(
AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
private func removeDeviceChangeListener() {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListener(
AudioObjectID(kAudioObjectSystemObject), &address, deviceChanged,
Unmanaged.passUnretained(self).toOpaque()
)
}
// MARK: - Callbacks
fileprivate func handleVolumeChange() {
let vol = getVolume()
let muted = isMuted()
callback(vol, muted)
}
fileprivate func handleMuteChange() {
let vol = getVolume()
let muted = isMuted()
callback(vol, muted)
}
fileprivate func handleDeviceChange() {
// Remove old listeners
if deviceID != kAudioObjectUnknown {
removeVolumeListener(for: deviceID)
removeMuteListener(for: deviceID)
}
// Switch to new device
deviceID = getDefaultOutputDevice()
if deviceID != kAudioObjectUnknown {
addVolumeListener(for: deviceID)
addMuteListener(for: deviceID)
}
}
}
// C-style callbacks that bridge to the class methods
private func volumeChanged(
_ objectID: AudioObjectID,
_ numberAddresses: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData = clientData else { return noErr }
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
monitor.handleVolumeChange()
return noErr
}
private func muteChanged(
_ objectID: AudioObjectID,
_ numberAddresses: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData = clientData else { return noErr }
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
monitor.handleMuteChange()
return noErr
}
private func deviceChanged(
_ objectID: AudioObjectID,
_ numberAddresses: UInt32,
_ addresses: UnsafePointer<AudioObjectPropertyAddress>,
_ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData = clientData else { return noErr }
let monitor = Unmanaged<VolumeMonitor>.fromOpaque(clientData).takeUnretainedValue()
DispatchQueue.main.async {
monitor.handleDeviceChange()
}
return noErr
}

View file

@ -0,0 +1,6 @@
import Cocoa
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()