commit cee343dd96645f2f99c4421ee9041e349594a9db Author: johnfrum1234 Date: Tue Mar 10 13:28:41 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a595d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +*.app +DerivedData/ +.DS_Store +PROGRESS.md diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..994cda0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "HUDini", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "HUDini", + path: "Sources/HUDini", + exclude: ["Resources"], + linkerSettings: [ + .linkedFramework("Cocoa"), + .linkedFramework("CoreAudio"), + .linkedFramework("AudioToolbox"), + ] + ) + ] +) diff --git a/Sources/HUDini/AppDelegate.swift b/Sources/HUDini/AppDelegate.swift new file mode 100644 index 0000000..fb055f0 --- /dev/null +++ b/Sources/HUDini/AppDelegate.swift @@ -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) + } +} diff --git a/Sources/HUDini/HUDPanel.swift b/Sources/HUDini/HUDPanel.swift new file mode 100644 index 0000000..75ba128 --- /dev/null +++ b/Sources/HUDini/HUDPanel.swift @@ -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) + }) + } +} diff --git a/Sources/HUDini/HUDView.swift b/Sources/HUDini/HUDView.swift new file mode 100644 index 0000000..62c39ed --- /dev/null +++ b/Sources/HUDini/HUDView.swift @@ -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.. + + + + CFBundleName + HUDini + CFBundleDisplayName + HUDini + CFBundleIdentifier + com.grosfrumos.hudini + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + CFBundleExecutable + HUDini + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + + diff --git a/Sources/HUDini/VolumeMonitor.swift b/Sources/HUDini/VolumeMonitor.swift new file mode 100644 index 0000000..f3465ff --- /dev/null +++ b/Sources/HUDini/VolumeMonitor.swift @@ -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.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.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.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, + _ clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData = clientData else { return noErr } + let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + monitor.handleVolumeChange() + return noErr +} + +private func muteChanged( + _ objectID: AudioObjectID, + _ numberAddresses: UInt32, + _ addresses: UnsafePointer, + _ clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData = clientData else { return noErr } + let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + monitor.handleMuteChange() + return noErr +} + +private func deviceChanged( + _ objectID: AudioObjectID, + _ numberAddresses: UInt32, + _ addresses: UnsafePointer, + _ clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData = clientData else { return noErr } + let monitor = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + DispatchQueue.main.async { + monitor.handleDeviceChange() + } + return noErr +} diff --git a/Sources/HUDini/main.swift b/Sources/HUDini/main.swift new file mode 100644 index 0000000..eb33820 --- /dev/null +++ b/Sources/HUDini/main.swift @@ -0,0 +1,6 @@ +import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7a269c9 --- /dev/null +++ b/build.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +APP_NAME="HUDini" +BUILD_DIR=".build" +APP_BUNDLE="$APP_NAME.app" + +echo "Building $APP_NAME..." +swift build -c release + +echo "Creating app bundle..." +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" + +# Copy binary +cp "$BUILD_DIR/release/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME" + +# Copy Info.plist +cp "Sources/$APP_NAME/Resources/Info.plist" "$APP_BUNDLE/Contents/" + +echo "Done! Created $APP_BUNDLE" +echo "" +echo "To run: open $APP_BUNDLE" +echo "To install: cp -r $APP_BUNDLE /Applications/"